From 2b667e58b0c971254334a80092400beaaf9473b3 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 12 Mar 2025 19:43:32 +0000 Subject: [PATCH 01/37] Release v4.23.0 --- dist/manifest.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dist/manifest.json b/dist/manifest.json index 5202b1a74a..9f9119dceb 100644 --- a/dist/manifest.json +++ b/dist/manifest.json @@ -1,10 +1,10 @@ [ { - "version": "4.22.0", - "sha1": "ec4359c0c6f353190466bab61901ca30fad2f86b", - "sha256": "911a4b3420533b2eeecd5fa6e54c7c5bcb1baf41c170627f1c29ea3b6c7f7cf5", + "version": "4.23.0", + "sha1": "1f12059d5f0cac4bb7ad5b4b52724a3a2a331d5e", + "sha256": "2723c13c2c9abec9913b2501ab83845c248ff32ab8b3473c8b51596f715dd092", "name": "platform.phar", - "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.22.0/platform.phar", + "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.23.0/platform.phar", "php": { "min": "5.5.9" }, @@ -72,7 +72,8 @@ "4.20.5": "* Fix environments listing by ID", "4.21.0": "New features:\n\n* Support listing teams for a single project.\n - The `team:list` (`teams`) command will now filter the teams list to those \n with access to the selected project, if any. The project is selected in the \n normal way, e.g. by `--project` (`-p`), or the current Git repository.\n - Use the `--all` option to list all the teams in the organization.\n - Add the `granted_at` column (`--columns +granted_at`) to see when the team \n was added to the project.\n\nOther changes:\n\n* Print necessary output in `--quiet` mode: \n Previously, the `--quiet` (`-q`) flag hid ALL output, on stderr and stdout. \n It now only hides message/error output (stderr), and continues to print \n necessary output (stdout).\n* Cache organization data locally (for up to 10 minutes by default).\n* Display the project's organization in the welcome command.\n* Silence output from `ssh-cert:load --refresh-only` (in non-verbose mode).\n* Add debug info for session storage.\n* Explain various actions being unavailable with a code source integration:\n synchronizing code, branching and merging.\n* Explain synchronizing being unavailable when the env or its parent is inactive.\n* Explain SSH unavailability when an env is paused.\n* Fix and improve project suspension warnings.\n* Improve formatting of Solr and PostgreSQL URLs.\n* Bump giggsey/libphonenumber-for-php from 8.13.45 to 8.13.46 (#1490)", "4.21.1": "* Re-allow output from \"ssh-cert:load --refresh-only\", now that quiet mode works (#1491)\n This partially reverts 29a92850a2ce8a4f583e23bba75dcf1fdaad44c3\n* After branching, only set the upstream if the remote exists\n* Skip the cache when updating an org via the org:info command\n* Do not require SSH permission to list mounts\n* Handle old envs with outdated \"has_remote\" in the env:delete command", - "4.22.0": "New features:\n\n* Add `--init-repo` option for the `create` (`project:create`) command, as a \n shortcut for creating a project from code in a public repository.\n* Before creating a project, check the organization's permissions. This uses the \n `can-create` API endpoint, which supports more potential action prompts than \n the previous version (which only supported phone verification), e.g. to create \n a support ticket or to update the organization's billing details.\n\nOther changes:\n\n* Make `redis` and `ssh` args consistent; allow multiple args in the `redis` command.\n* Fix error after creating a new organization from a project directory.\n* Fix the project list cache not clearing automatically." + "4.22.0": "New features:\n\n* Add `--init-repo` option for the `create` (`project:create`) command, as a \n shortcut for creating a project from code in a public repository.\n* Before creating a project, check the organization's permissions. This uses the \n `can-create` API endpoint, which supports more potential action prompts than \n the previous version (which only supported phone verification), e.g. to create \n a support ticket or to update the organization's billing details.\n\nOther changes:\n\n* Make `redis` and `ssh` args consistent; allow multiple args in the `redis` command.\n* Fix error after creating a new organization from a project directory.\n* Fix the project list cache not clearing automatically.", + "4.23.0": "New features:\n\n* Add Valkey support\n* Refresh the token and retry requests on a 401 error in :curl commands\n\nOther functional changes:\n\n* Rename \"web\" command to \"console\" (\"web\" remains as an alias)\n* Show \"(legacy)\" in the version output, if not wrapped.\n* Fix the console URL in the project:create (create) command\n* Fix duplication of commands in Markdown-formatted list\n* installer: fix use of GitHub credentials on container where available\n* Update composer/ca-bundle and drush/drush dependencies\n\nDevelopment-related changes:\n\n* Add Golang integration tests in `/go-tests`\n* Run tests and security checks in GitHub Actions\n* Use Composer 2 in dev builds\n* Remove composer-bin-plugin\n* Update countries script for new CLDR format" } }, { From b4bd0f9f0a409dc76249d5aa0fed1f6124cbf4c9 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 2 May 2025 09:08:22 +0100 Subject: [PATCH 02/37] Add mark_unwrapped_legacy config option --- config-defaults.yaml | 3 +++ config.yaml | 1 + src/Application.php | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/config-defaults.yaml b/config-defaults.yaml index f01b5c0c6f..c65ae797f7 100644 --- a/config-defaults.yaml +++ b/config-defaults.yaml @@ -71,6 +71,9 @@ application: # Disabled commands (a list of full command names). disabled_commands: [] + # Whether to mark this CLI as a 'legacy' version if not under the Go wrapper. + mark_unwrapped_legacy: false + # Disabled commands under the Go wrapper (a list of full command names). wrapped_disabled_commands: [] diff --git a/config.yaml b/config.yaml index 24bda44364..40fdf84fa6 100644 --- a/config.yaml +++ b/config.yaml @@ -12,6 +12,7 @@ application: manifest_url: 'https://platform.sh/cli/manifest.json' github_repo: 'platformsh/legacy-cli' + mark_unwrapped_legacy: true wrapped_disabled_commands: - self:install - self:update diff --git a/src/Application.php b/src/Application.php index fb5502bc17..682a66096f 100644 --- a/src/Application.php +++ b/src/Application.php @@ -537,7 +537,7 @@ public function setRunningViaMulti() public function getLongVersion() { // Show "(legacy)" in the version output, if not wrapped. - if (!$this->cliConfig->isWrapped()) { + if (!$this->cliConfig->isWrapped() && $this->cliConfig->get('application.mark_unwrapped_legacy')) { return sprintf('%s (legacy) %s', $this->cliConfig->get('application.name'), $this->cliConfig->getVersion()); } return sprintf('%s %s', $this->cliConfig->get('application.name'), $this->cliConfig->getVersion()); From 514bbcbe10362553135710356beba40b862ae10f Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 13 May 2025 14:06:18 +0100 Subject: [PATCH 03/37] Update Go test dependencies (#1533) --- .github/workflows/ci.yml | 4 ++-- go-tests/go.mod | 13 ++++++------- go-tests/go.sum | 35 +++++++++++++---------------------- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbb98dbf17..939a7eefb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,9 +45,9 @@ jobs: ./bin/platform self:build --no-composer-rebuild --yes --replace-version "$CI_COMMIT_REF_NAME"-"$CI_COMMIT_SHORT_SHA" --output platform.phar - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: 1.22 + go-version: 1.24 cache-dependency-path: cli/go.sum - name: Lint Go files diff --git a/go-tests/go.mod b/go-tests/go.mod index 59d62ae299..4ca65a6b65 100644 --- a/go-tests/go.mod +++ b/go-tests/go.mod @@ -1,19 +1,18 @@ module github.com/platformsh/legacy-cli/tests -go 1.22.9 +go 1.24 require ( - github.com/go-chi/chi/v5 v5.1.0 - github.com/platformsh/cli v0.0.0-20250115153051-60dcb793eb89 - github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.31.0 + github.com/go-chi/chi/v5 v5.2.1 + github.com/platformsh/cli v0.0.0-20250512110214-68e4962f0990 + github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.38.0 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/oklog/ulid/v2 v2.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go-tests/go.sum b/go-tests/go.sum index 332ea2f4e4..15afcc85e8 100644 --- a/go-tests/go.sum +++ b/go-tests/go.sum @@ -1,32 +1,23 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/platformsh/cli v0.0.0-20250115153051-60dcb793eb89 h1:+EN931RRA5tsviOEqw6HW62u6UusZJq7LJKY3EIYrE0= -github.com/platformsh/cli v0.0.0-20250115153051-60dcb793eb89/go.mod h1:b1v98rkg8bScSoo5gK8Fc1qRta1FUU8wElRf+bGvV5Y= +github.com/platformsh/cli v0.0.0-20250512110214-68e4962f0990 h1:0xJ3ftKO/9qB3KCMnGzEzKZizOEJgCGBfOQk1ts9szk= +github.com/platformsh/cli v0.0.0-20250512110214-68e4962f0990/go.mod h1:1OFXJCFPlXT4zSc1U/U3xSNh1UmuqOm9rz+FldYe8z8= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From ac5cce2ed6eb91eef20d45b1830055057946edfa Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 13 May 2025 14:10:58 +0100 Subject: [PATCH 04/37] Stop bypassing the organization endpoint for subscriptions (#1532) --- go-tests/project_create_test.go | 2 ++ src/Command/SubscriptionInfoCommand.php | 2 +- src/Service/Api.php | 22 +--------------------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/go-tests/project_create_test.go b/go-tests/project_create_test.go index f1258bcee2..66988bdd17 100644 --- a/go-tests/project_create_test.go +++ b/go-tests/project_create_test.go @@ -44,6 +44,8 @@ func TestProjectCreate(t *testing.T) { assert.Contains(t, stdErr, "Project ID: "+projectID) assert.Contains(t, stdErr, "Project title: "+title) assert.Contains(t, stdErr, "Console URL: "+consoleURL) + + f.Run("subscription:info", "-p", projectID) } func TestProjectCreate_CanCreateError(t *testing.T) { diff --git a/src/Command/SubscriptionInfoCommand.php b/src/Command/SubscriptionInfoCommand.php index f8e91e3db9..d63ac1768b 100644 --- a/src/Command/SubscriptionInfoCommand.php +++ b/src/Command/SubscriptionInfoCommand.php @@ -45,7 +45,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $id = $project->getSubscriptionId(); } - $subscription = $this->api()->loadSubscription($id, $project, $input->getArgument('value') !== null); + $subscription = $this->api()->loadSubscription($id, $project); if (!$subscription) { $this->stdErr->writeln(sprintf('Subscription not found: %s', $id)); diff --git a/src/Service/Api.php b/src/Service/Api.php index f40e96e5a4..c98e4107d9 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1438,14 +1438,13 @@ private function on403(ErrorEvent $event) * * @param string $id * @param Project|null $project - * @param bool $forWrite * * @throws \GuzzleHttp\Exception\RequestException * * @return false|Subscription * The subscription or false if not found. */ - public function loadSubscription($id, Project $project = null, $forWrite = true) + public function loadSubscription($id, Project $project = null) { $organizations_enabled = $this->config->getWithDefault('api.organizations', false); if (!$organizations_enabled) { @@ -1454,25 +1453,6 @@ public function loadSubscription($id, Project $project = null, $forWrite = true) return $this->getClient()->getSubscription($id); } - // Attempt to load the subscription directly. - // This is possible if the user is on the project's access list, or - // if the user has access to all subscriptions. - // However, while this legacy API works for reading, it won't always work for writing. - if (!$forWrite) { - try { - $subscription = $this->getClient()->getSubscription($id); - } catch (BadResponseException $e) { - if (!$e->getResponse() || $e->getResponse()->getStatusCode() !== 403) { - throw $e; - } - $subscription = false; - } - if ($subscription) { - $this->debug('Loaded the subscription directly'); - return $subscription; - } - } - // Use the project's organization, if known. $organizationId = null; if (isset($project)) { From 5b165793b339983c2ba3c30179a19b375da0150c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 4 Jun 2025 11:25:51 +0100 Subject: [PATCH 05/37] Increase the default limits for finding activities --- go-tests/activity_list_test.go | 48 +++++++++++++++++++ .../Activity/ActivityCancelCommand.php | 4 +- src/Command/Activity/ActivityCommandBase.php | 3 ++ src/Command/Activity/ActivityGetCommand.php | 2 +- src/Command/Activity/ActivityListCommand.php | 5 +- src/Command/Activity/ActivityLogCommand.php | 2 +- .../IntegrationActivityListCommand.php | 5 +- 7 files changed, 61 insertions(+), 8 deletions(-) diff --git a/go-tests/activity_list_test.go b/go-tests/activity_list_test.go index bb5d5a0e8e..3ae5fb1757 100644 --- a/go-tests/activity_list_test.go +++ b/go-tests/activity_list_test.go @@ -2,6 +2,7 @@ package tests import ( "net/http/httptest" + "strconv" "testing" "time" @@ -87,4 +88,51 @@ func TestActivityList(t *testing.T) { assertTrimmed(t, "complete", f.Run("act:get", "-p", projectID, "-e", ".", "act1", "-P", "state")) assertTrimmed(t, "2014-04-01T10:00:00+00:00", f.Run("act:get", "-p", projectID, "-e", ".", "act1", "-P", "created_at")) + + // Generate a longer list of activities. + var activities = make([]*mockapi.Activity, 30) + for i := range activities { + num := i + 1 + createdAt := aprilFoolsDay10am.Add(time.Duration(i) * time.Minute) + varName := "X" + strconv.Itoa(num) + activities[i] = &mockapi.Activity{ + ID: "act" + strconv.Itoa(num), + Type: "environment.variable.create", + State: "complete", + Result: "success", + CompletionPercent: 100, + Project: projectID, + Environments: []string{"main"}, + Description: "Mock User created variable " + varName + " on environment main", + Text: "Mock User created variable " + varName + " on environment main", + CreatedAt: createdAt, + UpdatedAt: createdAt, + } + } + apiHandler.SetProjectActivities(projectID, activities) + + assertTrimmed(t, ` +ID Created Description Progress State Result +act30 2014-04-01T10:29:00+00:00 Mock User created variable X30 on environment main 100% complete success +act29 2014-04-01T10:28:00+00:00 Mock User created variable X29 on environment main 100% complete success +act28 2014-04-01T10:27:00+00:00 Mock User created variable X28 on environment main 100% complete success +act27 2014-04-01T10:26:00+00:00 Mock User created variable X27 on environment main 100% complete success +act26 2014-04-01T10:25:00+00:00 Mock User created variable X26 on environment main 100% complete success`, + f.Run("act", "-p", projectID, "-e", ".", "--format", "plain", "--limit", "5")) + + assertTrimmed(t, ` +ID Created Description Progress State Result +act30 2014-04-01T10:29:00+00:00 Mock User created variable X30 on environment main 100% complete success +act29 2014-04-01T10:28:00+00:00 Mock User created variable X29 on environment main 100% complete success +act28 2014-04-01T10:27:00+00:00 Mock User created variable X28 on environment main 100% complete success +act27 2014-04-01T10:26:00+00:00 Mock User created variable X27 on environment main 100% complete success +act26 2014-04-01T10:25:00+00:00 Mock User created variable X26 on environment main 100% complete success +act25 2014-04-01T10:24:00+00:00 Mock User created variable X25 on environment main 100% complete success +act24 2014-04-01T10:23:00+00:00 Mock User created variable X24 on environment main 100% complete success +act23 2014-04-01T10:22:00+00:00 Mock User created variable X23 on environment main 100% complete success +act22 2014-04-01T10:21:00+00:00 Mock User created variable X22 on environment main 100% complete success +act21 2014-04-01T10:20:00+00:00 Mock User created variable X21 on environment main 100% complete success +act20 2014-04-01T10:19:00+00:00 Mock User created variable X20 on environment main 100% complete success +act19 2014-04-01T10:18:00+00:00 Mock User created variable X19 on environment main 100% complete success`, + f.Run("act", "-p", projectID, "-e", ".", "--format", "plain", "--limit", "12")) } diff --git a/src/Command/Activity/ActivityCancelCommand.php b/src/Command/Activity/ActivityCancelCommand.php index f40693acf5..8791afa6bb 100644 --- a/src/Command/Activity/ActivityCancelCommand.php +++ b/src/Command/Activity/ActivityCancelCommand.php @@ -59,7 +59,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $activity = $this->getSelectedProject() ->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel') ?: [], 'Activity'); + $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, self::DEFAULT_FIND_LIMIT, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel') ?: [], 'Activity'); if (!$activity) { $this->stdErr->writeln("Activity not found: $id"); @@ -67,7 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } } } else { - $activities = $loader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel'); + $activities = $loader->loadFromInput($apiResource, $input, self::DEFAULT_FIND_LIMIT, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel'); if (\count($activities) === 0) { $this->stdErr->writeln('No cancellable activities found'); diff --git a/src/Command/Activity/ActivityCommandBase.php b/src/Command/Activity/ActivityCommandBase.php index 65317b9bab..aea51a7efa 100644 --- a/src/Command/Activity/ActivityCommandBase.php +++ b/src/Command/Activity/ActivityCommandBase.php @@ -9,6 +9,9 @@ class ActivityCommandBase extends CommandBase implements CompletionAwareInterface { + const DEFAULT_LIST_LIMIT = 10; // Display a digestible number of activities by default. + const DEFAULT_FIND_LIMIT = 25; // This is the current limit per page of results. + public function completeOptionValues($optionName, CompletionContext $context) { switch ($optionName) { diff --git a/src/Command/Activity/ActivityGetCommand.php b/src/Command/Activity/ActivityGetCommand.php index d6876740e8..2b712aea25 100644 --- a/src/Command/Activity/ActivityGetCommand.php +++ b/src/Command/Activity/ActivityGetCommand.php @@ -66,7 +66,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $activity = $this->getSelectedProject() ->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity'); + $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, self::DEFAULT_FIND_LIMIT) ?: [], 'Activity'); if (!$activity) { $this->stdErr->writeln("Activity not found: $id"); diff --git a/src/Command/Activity/ActivityListCommand.php b/src/Command/Activity/ActivityListCommand.php index 7c29f6276d..4a93783427 100644 --- a/src/Command/Activity/ActivityListCommand.php +++ b/src/Command/Activity/ActivityListCommand.php @@ -56,7 +56,7 @@ protected function configure() . "\nThe % or * characters can be used as a wildcard to exclude types." ); - $this->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit the number of results displayed', 10) + $this->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit the number of results displayed', self::DEFAULT_LIST_LIMIT) ->addOption('start', null, InputOption::VALUE_REQUIRED, 'Only activities created before this date will be listed') ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter activities by state: in_progress, pending, complete, or cancelled.' . "\n" . ArrayArgument::SPLIT_HELP) ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter activities by result: success or failure') @@ -151,7 +151,8 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$table->formatIsMachineReadable()) { $executable = $this->config()->get('application.executable'); - $max = $input->getOption('limit') ? (int) $input->getOption('limit') : 10; + // TODO make this more deterministic by fetching limit+1 activities + $max = ((int) $input->getOption('limit') ?: self::DEFAULT_LIST_LIMIT); $maybeMoreAvailable = count($activities) === $max; if ($maybeMoreAvailable) { $this->stdErr->writeln(''); diff --git a/src/Command/Activity/ActivityLogCommand.php b/src/Command/Activity/ActivityLogCommand.php index 1880f1325f..92ad8af39b 100644 --- a/src/Command/Activity/ActivityLogCommand.php +++ b/src/Command/Activity/ActivityLogCommand.php @@ -71,7 +71,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $activity = $this->getSelectedProject() ->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity'); + $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, self::DEFAULT_FIND_LIMIT) ?: [], 'Activity'); if (!$activity) { $this->stdErr->writeln("Activity not found: $id"); diff --git a/src/Command/Integration/Activity/IntegrationActivityListCommand.php b/src/Command/Integration/Activity/IntegrationActivityListCommand.php index 9aff4f59d6..c2fec1dcd3 100644 --- a/src/Command/Integration/Activity/IntegrationActivityListCommand.php +++ b/src/Command/Integration/Activity/IntegrationActivityListCommand.php @@ -2,6 +2,7 @@ namespace Platformsh\Cli\Command\Integration\Activity; +use Platformsh\Cli\Command\Activity\ActivityCommandBase; use Platformsh\Cli\Command\Integration\IntegrationCommandBase; use Platformsh\Cli\Console\AdaptiveTableCell; use Platformsh\Cli\Console\ArrayArgument; @@ -50,7 +51,7 @@ protected function configure() . "\n" . ArrayArgument::SPLIT_HELP . "\nThe % or * characters can be used as a wildcard to exclude types." ) - ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit the number of results displayed', 10) + ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit the number of results displayed', ActivityCommandBase::DEFAULT_LIST_LIMIT) ->addOption('start', null, InputOption::VALUE_REQUIRED, 'Only activities created before this date will be listed') ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter activities by state.' . "\n" . ArrayArgument::SPLIT_HELP) ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter activities by result') @@ -123,7 +124,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$table->formatIsMachineReadable()) { $executable = $this->config()->get('application.executable'); - $max = $input->getOption('limit') ? (int) $input->getOption('limit') : 10; + $max = $input->getOption('limit') ? (int) $input->getOption('limit') : ActivityCommandBase::DEFAULT_LIST_LIMIT; $maybeMoreAvailable = count($activities) === $max; if ($maybeMoreAvailable) { $this->stdErr->writeln(''); From b1618a4848e58c6036598162cb52ba974aaaa943 Mon Sep 17 00:00:00 2001 From: vitolkachova Date: Fri, 6 Jun 2025 14:54:49 +0200 Subject: [PATCH 06/37] Add environment deploy command and staged activities (#1530) --- composer.lock | 16 +-- go-tests/environment_deploy_test.go | 79 ++++++++++++++ src/Application.php | 1 + src/Command/Activity/ActivityCommandBase.php | 2 +- .../Environment/EnvironmentDeployCommand.php | 101 ++++++++++++++++++ src/Service/ActivityLoader.php | 1 + src/Service/ActivityMonitor.php | 14 ++- 7 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 go-tests/environment_deploy_test.go create mode 100644 src/Command/Environment/EnvironmentDeployCommand.php diff --git a/composer.lock b/composer.lock index a5192394b0..1f2cadd221 100644 --- a/composer.lock +++ b/composer.lock @@ -921,16 +921,16 @@ }, { "name": "platformsh/client", - "version": "0.87.0", + "version": "0.88.0", "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "3ba1dde15d3fb34568c87f8b6a84515646e9c880" + "reference": "5bd737d40743d97ab1883d6a6e762abddaa7b121" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/3ba1dde15d3fb34568c87f8b6a84515646e9c880", - "reference": "3ba1dde15d3fb34568c87f8b6a84515646e9c880", + "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/5bd737d40743d97ab1883d6a6e762abddaa7b121", + "reference": "5bd737d40743d97ab1883d6a6e762abddaa7b121", "shasum": "" }, "require": { @@ -962,9 +962,9 @@ "description": "Platform.sh API client", "support": { "issues": "https://github.com/platformsh/platformsh-client-php/issues", - "source": "https://github.com/platformsh/platformsh-client-php/tree/0.87.0" + "source": "https://github.com/platformsh/platformsh-client-php/tree/0.88.0" }, - "time": "2024-11-14T08:02:39+00:00" + "time": "2025-05-14T14:08:57+00:00" }, { "name": "platformsh/console-form", @@ -4060,14 +4060,14 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=5.5.9", "ext-json": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "5.5.9" }, diff --git a/go-tests/environment_deploy_test.go b/go-tests/environment_deploy_test.go new file mode 100644 index 0000000000..252a267293 --- /dev/null +++ b/go-tests/environment_deploy_test.go @@ -0,0 +1,79 @@ +package tests + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/platformsh/cli/pkg/mockapi" +) + +func TestEnvironmentDeploy(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }, + }) + main := makeEnv(projectID, "main", "production", "active", nil) + main.Links["#activities"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/activities"} + main.Links["#deploy"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/deploy"} + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + created1, _ := time.Parse(time.RFC3339, "2014-04-01T10:00:00Z") + created2, _ := time.Parse(time.RFC3339, "2014-04-02T10:00:00Z") + updated, _ := time.Parse(time.RFC3339, "2014-04-02T11:00:00Z") + apiHandler.SetProjectActivities(projectID, []*mockapi.Activity{ + { + ID: "act1", + Type: "environment.push", + State: "staged", + Result: "success", + CompletionPercent: 100, + Project: projectID, + Environments: []string{"main"}, + Description: "Mock User pushed to main", + Text: "Mock User pushed to main", + CreatedAt: created1, + UpdatedAt: updated, + }, { + ID: "act2", + Type: "environment.variable.create", + State: "staged", + Result: "success", + CompletionPercent: 100, + Project: projectID, + Environments: []string{"main"}, + Description: "Mock User created variable X on environment main", + Text: "Mock User created variable X on environment main", + CreatedAt: created2, + UpdatedAt: updated, + }, + }) + + assertTrimmed(t, ` ++------+-------------------------+---------------------------------------------+-----------------------------+---------+ +| ID | Created | Description | Type | Result | ++------+-------------------------+---------------------------------------------+-----------------------------+---------+ +| act2 | 2014-04-02T10:00:00+00: | Mock User created variable X on environment | environment.variable.create | success | +| | 00 | main | | | +| act1 | 2014-04-01T10:00:00+00: | Mock User pushed to main | environment.push | success | +| | 00 | | | | ++------+-------------------------+---------------------------------------------+-----------------------------+---------+ +`, f.Run("env:deploy", "-p", projectID, "-e", "main")) +} diff --git a/src/Application.php b/src/Application.php index 682a66096f..265efee11c 100644 --- a/src/Application.php +++ b/src/Application.php @@ -147,6 +147,7 @@ protected function getCommands() $commands[] = new Command\Environment\EnvironmentCheckoutCommand(); $commands[] = new Command\Environment\EnvironmentCurlCommand(); $commands[] = new Command\Environment\EnvironmentDeleteCommand(); + $commands[] = new Command\Environment\EnvironmentDeployCommand(); $commands[] = new Command\Environment\EnvironmentDrushCommand(); $commands[] = new Command\Environment\EnvironmentHttpAccessCommand(); $commands[] = new Command\Environment\EnvironmentListCommand(); diff --git a/src/Command/Activity/ActivityCommandBase.php b/src/Command/Activity/ActivityCommandBase.php index 65317b9bab..ec5063c7dd 100644 --- a/src/Command/Activity/ActivityCommandBase.php +++ b/src/Command/Activity/ActivityCommandBase.php @@ -16,7 +16,7 @@ public function completeOptionValues($optionName, CompletionContext $context) case 'exclude-type': return ActivityLoader::getAvailableTypes(); case 'state': - return ['in_progress', 'pending', 'complete', 'cancelled']; + return ['in_progress', 'pending', 'complete', 'cancelled', 'staged']; case 'result': return ['success', 'failure']; } diff --git a/src/Command/Environment/EnvironmentDeployCommand.php b/src/Command/Environment/EnvironmentDeployCommand.php new file mode 100644 index 0000000000..d2bd4398ca --- /dev/null +++ b/src/Command/Environment/EnvironmentDeployCommand.php @@ -0,0 +1,101 @@ + 'ID', + 'created' => 'Created', + 'description' => 'Description', + 'type' => 'Type', + 'result' => 'Result', + ]; + + protected function configure() + { + $this + ->setName('environment:deploy') + ->setAliases(['deploy']) + ->setDescription('Deploy an environment\'s staged changes'); + $this->addProjectOption() + ->addEnvironmentOption(); + $this->addWaitOptions(); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->chooseEnvFilter = $this->filterEnvsByStatus(['active', 'paused']); + $this->validateInput($input); + + $environment = $this->getSelectedEnvironment(); + + if (!$environment->operationAvailable('deploy', true)) { + $this->stdErr->writeln( + "Operation not available: The environment " . $this->api()->getEnvironmentLabel($environment, 'error') . " can't be deployed." + ); + + if (!$environment->isActive()) { + $this->stdErr->writeln('The environment is not active.'); + } + + return 1; + } + + $activities = $environment->getActivities(0, null, null, Activity::STATE_STAGED); + if (count($activities) < 1) { + $this->stdErr->writeln(sprintf( + 'The environment %s has no staged changes to deploy.', + $this->api()->getEnvironmentLabel($environment, 'comment') + )); + return 0; + } + + /** @var \Platformsh\Cli\Service\Table $table */ + $table = $this->getService('table'); + /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ + $formatter = $this->getService('property_formatter'); + + $rows = []; + foreach ($activities as $activity) { + $row = [ + 'id' => new AdaptiveTableCell($activity->id, ['wrap' => false]), + 'created' => $formatter->format($activity['created_at'], 'created_at'), + 'description' => ActivityMonitor::getFormattedDescription($activity, !$table->formatIsMachineReadable()), + 'type' => new AdaptiveTableCell($activity->type, ['wrap' => false]), + 'result' => ActivityMonitor::formatResult($activity->result, !$table->formatIsMachineReadable()), + ]; + $rows[] = $row; + } + + $this->stdErr->writeln(sprintf('The following changes will be deployed to the environment %s:', + $this->api()->getEnvironmentLabel($environment, 'comment') + )); + $table->render($rows, $this->tableHeader); + + /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ + $questionHelper = $this->getService('question_helper'); + if (!$questionHelper->confirm('Are you sure you want to continue?')) { + return 1; + } + + $result = $environment->runOperation('deploy'); + + if ($this->shouldWait($input)) { + /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ + $activityMonitor = $this->getService('activity_monitor'); + $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if (!$success) { + return 1; + } + } + + return 0; + } +} diff --git a/src/Service/ActivityLoader.php b/src/Service/ActivityLoader.php index af365fdbb9..f716d81362 100644 --- a/src/Service/ActivityLoader.php +++ b/src/Service/ActivityLoader.php @@ -177,6 +177,7 @@ public static function getAvailableTypes() 'environment.cron', 'environment.deactivate', 'environment.delete', + 'environment.deploy', 'environment.domain.create', 'environment.domain.delete', 'environment.domain.update', diff --git a/src/Service/ActivityMonitor.php b/src/Service/ActivityMonitor.php index 0eb9f44eec..3c2841cc8a 100644 --- a/src/Service/ActivityMonitor.php +++ b/src/Service/ActivityMonitor.php @@ -121,7 +121,8 @@ public function waitAndLog(Activity $activity, $pollInterval = 3, $timestamps = // Exit the loop if the log finished and the activity is complete. if ($seal) { - if ($activity->isComplete() || $activity->state === Activity::STATE_CANCELLED) { + if ($activity->isComplete() || $activity->state === Activity::STATE_CANCELLED + || $activity->state === Activity::STATE_STAGED) { break; } continue; @@ -524,24 +525,27 @@ private function printResult(Activity $activity, $logOnFailure = false) switch ($activity->result) { case Activity::RESULT_SUCCESS: $stdErr->writeln('The activity succeeded: ' . self::getFormattedDescription($activity, true, true, 'green')); - return true; case Activity::RESULT_FAILURE: if ($activity->state === Activity::STATE_CANCELLED) { $stdErr->writeln('The activity was cancelled: ' . self::getFormattedDescription($activity, true, true, 'yellow')); - return false; } $stdErr->writeln('The activity failed: ' . self::getFormattedDescription($activity, true, true, 'red')); if ($logOnFailure) { $stdErr->writeln(' Log:'); $stdErr->writeln($this->indent($this->formatLog($activity->readLog()))); } - return false; default: $stdErr->writeln('The activity finished with an unknown result: ' . self::getFormattedDescription($activity, true, true, 'yellow')); - return false; } + + if ($activity->state === Activity::STATE_STAGED) { + $stdErr->writeln(sprintf('To deploy staged changes, run: %s deploy', + $this->config->get('application.executable'))); + } + + return true; } /** From 8d4db4e8ed327693dc5cccb4911b6ec1cecdc386 Mon Sep 17 00:00:00 2001 From: vitolkachova Date: Wed, 18 Jun 2025 14:26:41 +0200 Subject: [PATCH 07/37] fix activity result breaks (#1541) --- src/Service/ActivityMonitor.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Service/ActivityMonitor.php b/src/Service/ActivityMonitor.php index 3c2841cc8a..b06f755935 100644 --- a/src/Service/ActivityMonitor.php +++ b/src/Service/ActivityMonitor.php @@ -525,17 +525,18 @@ private function printResult(Activity $activity, $logOnFailure = false) switch ($activity->result) { case Activity::RESULT_SUCCESS: $stdErr->writeln('The activity succeeded: ' . self::getFormattedDescription($activity, true, true, 'green')); - + break; case Activity::RESULT_FAILURE: if ($activity->state === Activity::STATE_CANCELLED) { $stdErr->writeln('The activity was cancelled: ' . self::getFormattedDescription($activity, true, true, 'yellow')); + break; } $stdErr->writeln('The activity failed: ' . self::getFormattedDescription($activity, true, true, 'red')); if ($logOnFailure) { $stdErr->writeln(' Log:'); $stdErr->writeln($this->indent($this->formatLog($activity->readLog()))); } - + break; default: $stdErr->writeln('The activity finished with an unknown result: ' . self::getFormattedDescription($activity, true, true, 'yellow')); } From 24d87264ca16985a2b30b1c13b15447d6c24c77f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:48:49 +0100 Subject: [PATCH 08/37] Bump github.com/go-chi/chi/v5 from 5.2.1 to 5.2.2 in /go-tests (#1542) Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.2.1 to 5.2.2. - [Release notes](https://github.com/go-chi/chi/releases) - [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md) - [Commits](https://github.com/go-chi/chi/compare/v5.2.1...v5.2.2) --- updated-dependencies: - dependency-name: github.com/go-chi/chi/v5 dependency-version: 5.2.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go-tests/go.mod | 2 +- go-tests/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go-tests/go.mod b/go-tests/go.mod index 4ca65a6b65..a016e3c8e6 100644 --- a/go-tests/go.mod +++ b/go-tests/go.mod @@ -3,7 +3,7 @@ module github.com/platformsh/legacy-cli/tests go 1.24 require ( - github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/chi/v5 v5.2.2 github.com/platformsh/cli v0.0.0-20250512110214-68e4962f0990 github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.38.0 diff --git a/go-tests/go.sum b/go-tests/go.sum index 15afcc85e8..25bdf5cfd8 100644 --- a/go-tests/go.sum +++ b/go-tests/go.sum @@ -1,7 +1,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= From 6b2c7a4184befef876b6cffb0b525fed53dd7b4f Mon Sep 17 00:00:00 2001 From: Muhammad Inam Date: Tue, 15 Jul 2025 19:39:17 +0530 Subject: [PATCH 09/37] Guaranteed resources support. (#1531) --- config-defaults.yaml | 3 ++ config.yaml | 6 ++++ .../Resources/ResourcesCommandBase.php | 14 ++++++++++ src/Command/Resources/ResourcesGetCommand.php | 13 +++++++-- src/Command/Resources/ResourcesSetCommand.php | 28 ++++++++++++++++++- .../Resources/ResourcesSizeListCommand.php | 27 +++++++++++++++--- 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/config-defaults.yaml b/config-defaults.yaml index c65ae797f7..55f3072303 100644 --- a/config-defaults.yaml +++ b/config-defaults.yaml @@ -396,6 +396,9 @@ warnings: # without the capability. non_production_domains_msg: null + # The message shown when configuring guaranteed resources. + guaranteed_resources_msg: null + # Configuration for informational messages. messages: # A message about discounts where a region choice is displayed. diff --git a/config.yaml b/config.yaml index 40fdf84fa6..a11f805336 100644 --- a/config.yaml +++ b/config.yaml @@ -75,3 +75,9 @@ warnings: Otherwise contact sales first to upgrade your plan. See: https://docs.platform.sh/overview/get-support.html + + guaranteed_resources_msg: | + You have chosen to allocate guaranteed resources for app(s) and/or service(s). + This change may affect resource costs. See: https://upsun.com/pricing/ + + This process requires a redeployment of containers on their own host, which may take a few minutes to complete. diff --git a/src/Command/Resources/ResourcesCommandBase.php b/src/Command/Resources/ResourcesCommandBase.php index f1bda55b9f..73cdccfc92 100644 --- a/src/Command/Resources/ResourcesCommandBase.php +++ b/src/Command/Resources/ResourcesCommandBase.php @@ -205,4 +205,18 @@ protected function formatCPU($unformatted) { return sprintf('%.1f', $unformatted); } + + /** + * Check if project supports guaranteed resources. + * + * @param array $projectInfo + * + * @return bool + * True if guaranteed CPU is supported, false otherwise. + */ + protected function supportsGuaranteedCPU(array $projectInfo) + { + return !empty($projectInfo["settings"]["enable_guaranteed_resources"]) && + !empty($projectInfo["capabilities"]["guaranteed_resources"]["enabled"]); + } } diff --git a/src/Command/Resources/ResourcesGetCommand.php b/src/Command/Resources/ResourcesGetCommand.php index 3693d8eefb..c3d03e5295 100644 --- a/src/Command/Resources/ResourcesGetCommand.php +++ b/src/Command/Resources/ResourcesGetCommand.php @@ -16,6 +16,7 @@ class ResourcesGetCommand extends ResourcesCommandBase 'type' => 'Type', 'profile' => 'Profile', 'profile_size' => 'Size', + 'cpu_type' => 'CPU type', 'cpu' => 'CPU', 'memory' => 'Memory (MB)', 'disk' => 'Disk (MB)', @@ -23,7 +24,7 @@ class ResourcesGetCommand extends ResourcesCommandBase 'base_memory' => 'Base memory', 'memory_ratio' => 'Memory ratio', ]; - protected $defaultColumns = ['service', 'profile_size', 'cpu', 'memory', 'disk', 'instance_count']; + protected $defaultColumns = ['service', 'profile_size', 'cpu_type', 'cpu', 'memory', 'disk', 'instance_count']; protected function configure() { @@ -33,7 +34,8 @@ protected function configure() ->addOption('service', 's', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by service name. This can select any service, including apps and workers.') ->addOption('app', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by app name') ->addOption('worker', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by worker name') - ->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service, app or worker type, e.g. "postgresql"'); + ->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service, app or worker type, e.g. "postgresql"') + ->addOption('cpu-type', null, InputOption::VALUE_OPTIONAL, 'Filter by CPU type, e.g "guaranteed"'); $this->addProjectOption()->addEnvironmentOption(); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); if ($this->config()->has('service.resources_help_url')) { @@ -88,6 +90,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $containerProfiles = $nextDeployment->container_profiles; $rows = []; + $cpuTypeOption = $input->getOption('cpu-type'); foreach ($services as $name => $service) { $properties = $service->getProperties(); $row = [ @@ -99,12 +102,18 @@ protected function execute(InputInterface $input, OutputInterface $output) 'memory_ratio' => $empty, 'disk' => $empty, 'instance_count' => $empty, + 'cpu_type' => $empty, 'cpu' => $empty, 'memory' => $empty, ]; if (isset($properties['container_profile']) && isset($containerProfiles[$properties['container_profile']][$properties['resources']['profile_size']])) { $profileInfo = $containerProfiles[$properties['container_profile']][$properties['resources']['profile_size']]; + if ($cpuTypeOption != "" && isset($profileInfo['cpu_type']) && $profileInfo['cpu_type'] != $cpuTypeOption) { + continue; + } + + $row['cpu_type'] = isset($profileInfo['cpu_type']) ? $profileInfo['cpu_type'] : ''; $row['cpu'] = isset($profileInfo['cpu']) ? $this->formatCPU($profileInfo['cpu']) : ''; $row['memory'] = isset($profileInfo['cpu']) ? $profileInfo['memory'] : ''; } diff --git a/src/Command/Resources/ResourcesSetCommand.php b/src/Command/Resources/ResourcesSetCommand.php index 7b3d759d48..e014e4fe0e 100644 --- a/src/Command/Resources/ResourcesSetCommand.php +++ b/src/Command/Resources/ResourcesSetCommand.php @@ -130,6 +130,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $containerProfiles = $nextDeployment->container_profiles; + // Remove guaranteed profiles if project does not support it. + $supportsGuaranteedCPU = $this->supportsGuaranteedCPU($nextDeployment->project_info); + foreach ($containerProfiles as $profileName => $profile) { + foreach ($profile as $sizeName => $sizeInfo) { + if (!$supportsGuaranteedCPU && $sizeInfo['cpu_type'] == 'guaranteed') { + unset($containerProfiles[$profileName][$sizeName]); + } + } + } + // Ask all questions if nothing was specified on the command line. $showCompleteForm = $input->isInteractive() && $input->getOption('size') === [] @@ -138,6 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $updates = []; $current = []; + $hasGuaranteedCPU = false; foreach ($services as $name => $service) { $type = $this->typeName($service); $group = $this->group($service); @@ -209,6 +220,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $errored = true; } + // Check if we have guaranteed CPU changes. + if (isset($updates[$group][$name]['resources']['profile_size'])) { + $serviceProfileSize = $updates[$group][$name]['resources']['profile_size']; + $serviceProfileType = $properties['container_profile']; + if (isset($containerProfiles[$serviceProfileType][$serviceProfileSize]) && $containerProfiles[$serviceProfileType][$serviceProfileSize]['cpu_type'] == 'guaranteed') { + $hasGuaranteedCPU = true; + } + } + // Set the instance count. if (!$service instanceof Service) { // a Service instance count cannot be changed if (isset($givenCounts[$name])) { @@ -319,7 +339,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + + $questionText = 'Are you sure you want to continue?'; + if ($hasGuaranteedCPU && $this->config()->has('warnings.guaranteed_resources_msg')) { + $questionText = trim($this->config()->get('warnings.guaranteed_resources_msg')) + . "\n\n" . "Are you sure you want to continue?"; + } + if (!$questionHelper->confirm($questionText)) { return 1; } diff --git a/src/Command/Resources/ResourcesSizeListCommand.php b/src/Command/Resources/ResourcesSizeListCommand.php index 62f0d52436..5e13f1d48d 100644 --- a/src/Command/Resources/ResourcesSizeListCommand.php +++ b/src/Command/Resources/ResourcesSizeListCommand.php @@ -10,7 +10,13 @@ class ResourcesSizeListCommand extends ResourcesCommandBase { - protected $tableHeader = ['size' => 'Size name', 'cpu' => 'CPU', 'memory' => 'Memory (MB)']; + protected $tableHeader = [ + 'size' => 'Size name', + 'cpu' => 'CPU', + 'memory' => 'Memory (MB)', + 'cpu_type' => 'CPU type', + ]; + protected $defaultColumns = ['size', 'cpu', 'memory']; protected function configure() { @@ -20,7 +26,7 @@ protected function configure() ->addOption('service', 's', InputOption::VALUE_REQUIRED, 'A service name') ->addOption('profile', null, InputOption::VALUE_REQUIRED, 'A profile name'); $this->addProjectOption()->addEnvironmentOption(); - Table::configureInput($this->getDefinition(), $this->tableHeader); + Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } protected function execute(InputInterface $input, OutputInterface $output) @@ -78,8 +84,21 @@ protected function execute(InputInterface $input, OutputInterface $output) $table = $this->getService('table'); $rows = []; + $supportsGuaranteedCPU = $this->supportsGuaranteedCPU($nextDeployment->project_info); + $defaultColumns = $this->defaultColumns; + if ($supportsGuaranteedCPU) { + $defaultColumns[] = 'cpu_type'; + } foreach ($containerProfiles[$profile] as $sizeName => $sizeInfo) { - $rows[] = ['size' => $sizeName, 'cpu' => $this->formatCPU($sizeInfo['cpu']), 'memory' => $sizeInfo['memory']]; + if (!$supportsGuaranteedCPU && $sizeInfo['cpu_type'] == 'guaranteed') { + continue; + } + $rows[] = [ + 'size' => $sizeName, + 'cpu' => $this->formatCPU($sizeInfo['cpu']), + 'memory' => $sizeInfo['memory'], + 'cpu_type' => isset($sizeInfo['cpu_type']) ? $sizeInfo['cpu_type'] : '', + ]; } if (!$table->formatIsMachineReadable()) { @@ -95,7 +114,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $table->render($rows, $this->tableHeader); + $table->render($rows, $this->tableHeader, $defaultColumns); return 0; } From 4b42242ef6e9b48de79f29cabd43ff486384f011 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 18 Jul 2025 10:53:00 +0100 Subject: [PATCH 10/37] Remove "deploy" alias from env:deploy command --- src/Command/Environment/EnvironmentDeployCommand.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Command/Environment/EnvironmentDeployCommand.php b/src/Command/Environment/EnvironmentDeployCommand.php index d2bd4398ca..e1886df20f 100644 --- a/src/Command/Environment/EnvironmentDeployCommand.php +++ b/src/Command/Environment/EnvironmentDeployCommand.php @@ -22,7 +22,6 @@ protected function configure() { $this ->setName('environment:deploy') - ->setAliases(['deploy']) ->setDescription('Deploy an environment\'s staged changes'); $this->addProjectOption() ->addEnvironmentOption(); From ac82212a54b0cfcbe2619c8396b1d81b3abcdc05 Mon Sep 17 00:00:00 2001 From: vitolkachova Date: Fri, 1 Aug 2025 01:11:10 +0200 Subject: [PATCH 11/37] Add an environment:deploy:type command --- composer.json | 2 +- composer.lock | 14 +-- go-tests/environment_deploy_type_test.go | 77 +++++++++++++++ go-tests/go.mod | 2 +- go-tests/go.sum | 4 +- src/Application.php | 1 + .../Environment/EnvironmentDeployCommand.php | 1 + .../EnvironmentDeployTypeCommand.php | 96 +++++++++++++++++++ 8 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 go-tests/environment_deploy_type_test.go create mode 100644 src/Command/Environment/EnvironmentDeployTypeCommand.php diff --git a/composer.json b/composer.json index 14a2f0aad4..1711e547e1 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "guzzlehttp/guzzle": "^5.3", "guzzlehttp/ringphp": "^1.1", "platformsh/console-form": ">=0.0.37 <2.0", - "platformsh/client": ">=0.87.0 <2.0", + "platformsh/client": ">=0.89.0 <2.0", "symfony/console": "^3.0 >=3.2", "symfony/yaml": "^3.0 || ^2.6", "symfony/finder": "^3.0", diff --git a/composer.lock b/composer.lock index 1f2cadd221..1211ab1801 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9aa7432a5a81e5e0ca9114a9c76dd675", + "content-hash": "550801f8c7ed5c5253406b5404429b0d", "packages": [ { "name": "cocur/slugify", @@ -921,16 +921,16 @@ }, { "name": "platformsh/client", - "version": "0.88.0", + "version": "0.89.0", "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "5bd737d40743d97ab1883d6a6e762abddaa7b121" + "reference": "9ca1adcf3f7a1e996d30a564f995577644cfb4df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/5bd737d40743d97ab1883d6a6e762abddaa7b121", - "reference": "5bd737d40743d97ab1883d6a6e762abddaa7b121", + "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/9ca1adcf3f7a1e996d30a564f995577644cfb4df", + "reference": "9ca1adcf3f7a1e996d30a564f995577644cfb4df", "shasum": "" }, "require": { @@ -962,9 +962,9 @@ "description": "Platform.sh API client", "support": { "issues": "https://github.com/platformsh/platformsh-client-php/issues", - "source": "https://github.com/platformsh/platformsh-client-php/tree/0.88.0" + "source": "https://github.com/platformsh/platformsh-client-php/tree/0.89.0" }, - "time": "2025-05-14T14:08:57+00:00" + "time": "2025-07-31T18:59:15+00:00" }, { "name": "platformsh/console-form", diff --git a/go-tests/environment_deploy_type_test.go b/go-tests/environment_deploy_type_test.go new file mode 100644 index 0000000000..be19b6055e --- /dev/null +++ b/go-tests/environment_deploy_type_test.go @@ -0,0 +1,77 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "github.com/platformsh/cli/pkg/mockapi" + "github.com/stretchr/testify/assert" +) + +func TestEnvironmentDeployType(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{ + { + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }, + }) + main := makeEnv(projectID, "main", "production", "active", nil) + main.SetSetting("enable_manual_deployments", false) + main.Links["#deploy"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/deploy"} + apiHandler.SetEnvironments([]*mockapi.Environment{main, makeEnv(projectID, "dev", "development", "inactive", nil)}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + expectedStderrPrefix := "Selected project: " + projectID + "\nSelected environment: main (type: production)\n\n" + + _, stdErr, _ := f.RunCombinedOutput("environment:deploy:type", "-p", projectID, "-e", "main") + assert.Equal(t, expectedStderrPrefix+"Deployment type: automatic\n", stdErr) + + _, stdErr, _ = f.RunCombinedOutput("env:deploy:type", "automatic", "-p", projectID, "-e", "main") + assert.Equal(t, expectedStderrPrefix+"The deployment type is already automatic.\n", stdErr) + + _, _, err := f.RunCombinedOutput("env:deploy:type", "invalid", "-p", projectID, "-e", "main") + assert.Error(t, err) + + _, stdErr, _ = f.RunCombinedOutput("env:deploy:type", "manual", "-p", projectID, "-e", "main") + assert.Equal(t, expectedStderrPrefix+"Changing the deployment type from automatic to manual...\n"+ + "The deployment type was updated successfully to: manual\n", stdErr) + + apiHandler.SetProjectActivities(projectID, []*mockapi.Activity{ + { + ID: "act1", + Type: "environment.push", + State: "staged", + Result: "success", + CompletionPercent: 100, + Project: projectID, + Environments: []string{"main"}, + Description: "Mock User pushed to main", + Text: "Mock User pushed to main", + }, + }) + + _, stdErr, _ = f.RunCombinedOutput("env:deploy:type", "automatic", "-p", projectID, "-e", "main") + assert.Equal(t, expectedStderrPrefix+ + "Changing the deployment type from manual to automatic...\n"+ + "Updating this setting will immediately deploy staged changes.\n"+ + "Are you sure you want to continue? [Y/n] y\n"+ + "The deployment type was updated successfully to: automatic\n", stdErr) + + _, stdErr, _ = f.RunCombinedOutput("env:deploy:type", "manual", "-p", projectID, "-e", "dev") + assert.Equal(t, "Selected project: "+projectID+"\nSelected environment: dev (type: development)\n\n"+ + "The manual deployment type is not available as the environment is not active.\n", stdErr) +} diff --git a/go-tests/go.mod b/go-tests/go.mod index a016e3c8e6..9096c60a58 100644 --- a/go-tests/go.mod +++ b/go-tests/go.mod @@ -4,7 +4,7 @@ go 1.24 require ( github.com/go-chi/chi/v5 v5.2.2 - github.com/platformsh/cli v0.0.0-20250512110214-68e4962f0990 + github.com/platformsh/cli v0.0.0-20250731203409-d16c54a147ad github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.38.0 ) diff --git a/go-tests/go.sum b/go-tests/go.sum index 25bdf5cfd8..37826cdb5f 100644 --- a/go-tests/go.sum +++ b/go-tests/go.sum @@ -5,8 +5,8 @@ github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hH github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/platformsh/cli v0.0.0-20250512110214-68e4962f0990 h1:0xJ3ftKO/9qB3KCMnGzEzKZizOEJgCGBfOQk1ts9szk= -github.com/platformsh/cli v0.0.0-20250512110214-68e4962f0990/go.mod h1:1OFXJCFPlXT4zSc1U/U3xSNh1UmuqOm9rz+FldYe8z8= +github.com/platformsh/cli v0.0.0-20250731203409-d16c54a147ad h1:8zVPD4Pnyxlfo+CoW6z4xWbKJ24SkFsawcpOA9VxOuo= +github.com/platformsh/cli v0.0.0-20250731203409-d16c54a147ad/go.mod h1:R6GngeR46fJCjZvpoqR+7ccNRLXTHSKzKUQUoDy+6ks= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/src/Application.php b/src/Application.php index 265efee11c..7d08ea9149 100644 --- a/src/Application.php +++ b/src/Application.php @@ -148,6 +148,7 @@ protected function getCommands() $commands[] = new Command\Environment\EnvironmentCurlCommand(); $commands[] = new Command\Environment\EnvironmentDeleteCommand(); $commands[] = new Command\Environment\EnvironmentDeployCommand(); + $commands[] = new Command\Environment\EnvironmentDeployTypeCommand(); $commands[] = new Command\Environment\EnvironmentDrushCommand(); $commands[] = new Command\Environment\EnvironmentHttpAccessCommand(); $commands[] = new Command\Environment\EnvironmentListCommand(); diff --git a/src/Command/Environment/EnvironmentDeployCommand.php b/src/Command/Environment/EnvironmentDeployCommand.php index e1886df20f..e8e0f6a719 100644 --- a/src/Command/Environment/EnvironmentDeployCommand.php +++ b/src/Command/Environment/EnvironmentDeployCommand.php @@ -22,6 +22,7 @@ protected function configure() { $this ->setName('environment:deploy') + ->setAliases(['env:deploy']) ->setDescription('Deploy an environment\'s staged changes'); $this->addProjectOption() ->addEnvironmentOption(); diff --git a/src/Command/Environment/EnvironmentDeployTypeCommand.php b/src/Command/Environment/EnvironmentDeployTypeCommand.php new file mode 100644 index 0000000000..fca66473a3 --- /dev/null +++ b/src/Command/Environment/EnvironmentDeployTypeCommand.php @@ -0,0 +1,96 @@ +setName('environment:deploy:type') + ->setDescription('Show or set the environment deployment type') + ->addArgument('type', InputArgument::OPTIONAL, 'The environment deployment type: automatic or manual.') + ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the deployment type to stdout'); + $this->addProjectOption() + ->addEnvironmentOption(); + $this->addWaitOptions(); + $this->addExample('Set the deployment type to "manual" (disable automatic deployments)', 'manual'); + $this->setHelp("Choose automatic (the default) if you want your changes to be deployed immediately as they are made." + ."\nChoose manual to have changes staged until you trigger a deployment (including changes to code, variables, domains and settings)."); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->chooseEnvFilter = $this->filterEnvsByStatus(['active', 'paused']); + $this->validateInput($input); + + $environment = $this->getSelectedEnvironment(); + $settings = $environment->getSettings(); + $currentType = $settings->enable_manual_deployments ? 'manual' : 'automatic'; + + $newType = $input->getArgument('type'); + if ($newType === null) { + if ($input->getOption('pipe')) { + $output->writeln($currentType); + return 0; + } + $this->ensurePrintSelectedEnvironment(true); + $this->stdErr->writeln(sprintf('Deployment type: %s', $currentType)); + return 0; + } + + if ($newType !== 'manual' && $newType !== 'automatic') { + throw new InvalidArgumentException(sprintf('Invalid value "%s": the deployment type must be one of "automatic" or "manual".', $newType)); + } + + $this->ensurePrintSelectedEnvironment(true); + + if ($newType === $currentType) { + $this->stdErr->writeln(sprintf('The deployment type is already %s.', $currentType)); + return 0; + } + + if ($newType === 'manual' && !$environment->isActive()) { + $this->stdErr->writeln('The manual deployment type is not available as the environment is not active.'); + return 0; + } + + $this->stdErr->writeln(sprintf('Changing the deployment type from %s to %s...', + $currentType, $newType)); + + if ($newType == 'automatic') { + $activities = $environment->getActivities(0, null, null, Activity::STATE_STAGED); + if (count($activities) > 0) { + $this->stdErr->writeln('Updating this setting will immediately deploy staged changes.'); + /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ + $questionHelper = $this->getService('question_helper'); + if (!$questionHelper->confirm('Are you sure you want to continue?')) { + return 1; + } + } + } + + $result = $settings->update(['enable_manual_deployments' => $newType === 'manual']); + + if ($result->getActivities() && $this->shouldWait($input)) { + /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ + $activityMonitor = $this->getService('activity_monitor'); + $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if (!$success) { + return 1; + } + } + + $this->stdErr->writeln(sprintf('The deployment type was updated successfully to: %s', $newType)); + + return 0; + } +} From 3691a5069d926758bc3d7c2e81129ebe9412c0d7 Mon Sep 17 00:00:00 2001 From: vitolkachova Date: Fri, 1 Aug 2025 01:25:47 +0200 Subject: [PATCH 12/37] Add read-only "deployment_type" to env:info (#1540) --- go-tests/environment_info_test.go | 5 ++++- src/Command/Environment/EnvironmentInfoCommand.php | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/go-tests/environment_info_test.go b/go-tests/environment_info_test.go index dd2de4fc87..c087e92242 100644 --- a/go-tests/environment_info_test.go +++ b/go-tests/environment_info_test.go @@ -27,8 +27,10 @@ func TestEnvironmentInfo(t *testing.T) { }, }) + prod := makeEnv(projectID, "main", "production", "active", nil) + prod.SetSetting("enable_manual_deployments", true) apiHandler.SetEnvironments([]*mockapi.Environment{ - makeEnv(projectID, "main", "production", "active", nil), + prod, makeEnv(projectID, "staging", "staging", "active", "main"), }) @@ -52,6 +54,7 @@ parent null project `+projectID+` created_at 2014-04-01T10:00:00+00:00 updated_at 2014-04-01T11:00:00+00:00 +deployment_type manual `, f.Run("env:info", "-p", projectID, "-e", ".", "--format", "plain", "--refresh", "-vvv")) assert.Equal(t, "2014-04-01\n", f.Run("env:info", "-p", projectID, "-e", ".", "created_at", "--date-fmt", "Y-m-d")) diff --git a/src/Command/Environment/EnvironmentInfoCommand.php b/src/Command/Environment/EnvironmentInfoCommand.php index a7b2687ff2..22d474ca11 100644 --- a/src/Command/Environment/EnvironmentInfoCommand.php +++ b/src/Command/Environment/EnvironmentInfoCommand.php @@ -92,6 +92,10 @@ protected function listProperties(Environment $environment) $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); $values[] = $this->formatter->format($value, $key); } + + $headings[] = 'deployment_type'; + $values[] = $environment->getSettings()->enable_manual_deployments ? 'manual' : 'automatic'; + /** @var \Platformsh\Cli\Service\Table $table */ $table = $this->getService('table'); $table->renderSimple($values, $headings); @@ -196,6 +200,14 @@ protected function getType($property) */ protected function validateValue($property, $value) { + if ($property == 'deployment_type') { + $this->stdErr->writeln( + 'Set the deployment type with: ' . $this->config()->get('application.executable') + . ' environment:deploy:type' + ); + return false; + } + $type = $this->getType($property); if (!$type) { $this->stdErr->writeln("Property not writable: $property"); From aaef1b81285fc95bd9b2282b12e4a6bfb70f6487 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 1 Aug 2025 00:34:00 +0100 Subject: [PATCH 13/37] Release v4.24.0 --- dist/manifest.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dist/manifest.json b/dist/manifest.json index 9f9119dceb..b967470b5f 100644 --- a/dist/manifest.json +++ b/dist/manifest.json @@ -1,10 +1,10 @@ [ { - "version": "4.23.0", - "sha1": "1f12059d5f0cac4bb7ad5b4b52724a3a2a331d5e", - "sha256": "2723c13c2c9abec9913b2501ab83845c248ff32ab8b3473c8b51596f715dd092", + "version": "4.24.0", + "sha1": "d20b52a3a251ff0cc29205c788858ccdda0c4d3d", + "sha256": "ccc78282dff95541bd92363d484fc4839e4fd9c881d2edaf449b4c4de14e3abf", "name": "platform.phar", - "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.23.0/platform.phar", + "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.24.0/platform.phar", "php": { "min": "5.5.9" }, @@ -73,7 +73,8 @@ "4.21.0": "New features:\n\n* Support listing teams for a single project.\n - The `team:list` (`teams`) command will now filter the teams list to those \n with access to the selected project, if any. The project is selected in the \n normal way, e.g. by `--project` (`-p`), or the current Git repository.\n - Use the `--all` option to list all the teams in the organization.\n - Add the `granted_at` column (`--columns +granted_at`) to see when the team \n was added to the project.\n\nOther changes:\n\n* Print necessary output in `--quiet` mode: \n Previously, the `--quiet` (`-q`) flag hid ALL output, on stderr and stdout. \n It now only hides message/error output (stderr), and continues to print \n necessary output (stdout).\n* Cache organization data locally (for up to 10 minutes by default).\n* Display the project's organization in the welcome command.\n* Silence output from `ssh-cert:load --refresh-only` (in non-verbose mode).\n* Add debug info for session storage.\n* Explain various actions being unavailable with a code source integration:\n synchronizing code, branching and merging.\n* Explain synchronizing being unavailable when the env or its parent is inactive.\n* Explain SSH unavailability when an env is paused.\n* Fix and improve project suspension warnings.\n* Improve formatting of Solr and PostgreSQL URLs.\n* Bump giggsey/libphonenumber-for-php from 8.13.45 to 8.13.46 (#1490)", "4.21.1": "* Re-allow output from \"ssh-cert:load --refresh-only\", now that quiet mode works (#1491)\n This partially reverts 29a92850a2ce8a4f583e23bba75dcf1fdaad44c3\n* After branching, only set the upstream if the remote exists\n* Skip the cache when updating an org via the org:info command\n* Do not require SSH permission to list mounts\n* Handle old envs with outdated \"has_remote\" in the env:delete command", "4.22.0": "New features:\n\n* Add `--init-repo` option for the `create` (`project:create`) command, as a \n shortcut for creating a project from code in a public repository.\n* Before creating a project, check the organization's permissions. This uses the \n `can-create` API endpoint, which supports more potential action prompts than \n the previous version (which only supported phone verification), e.g. to create \n a support ticket or to update the organization's billing details.\n\nOther changes:\n\n* Make `redis` and `ssh` args consistent; allow multiple args in the `redis` command.\n* Fix error after creating a new organization from a project directory.\n* Fix the project list cache not clearing automatically.", - "4.23.0": "New features:\n\n* Add Valkey support\n* Refresh the token and retry requests on a 401 error in :curl commands\n\nOther functional changes:\n\n* Rename \"web\" command to \"console\" (\"web\" remains as an alias)\n* Show \"(legacy)\" in the version output, if not wrapped.\n* Fix the console URL in the project:create (create) command\n* Fix duplication of commands in Markdown-formatted list\n* installer: fix use of GitHub credentials on container where available\n* Update composer/ca-bundle and drush/drush dependencies\n\nDevelopment-related changes:\n\n* Add Golang integration tests in `/go-tests`\n* Run tests and security checks in GitHub Actions\n* Use Composer 2 in dev builds\n* Remove composer-bin-plugin\n* Update countries script for new CLDR format" + "4.23.0": "New features:\n\n* Add Valkey support\n* Refresh the token and retry requests on a 401 error in :curl commands\n\nOther functional changes:\n\n* Rename \"web\" command to \"console\" (\"web\" remains as an alias)\n* Show \"(legacy)\" in the version output, if not wrapped.\n* Fix the console URL in the project:create (create) command\n* Fix duplication of commands in Markdown-formatted list\n* installer: fix use of GitHub credentials on container where available\n* Update composer/ca-bundle and drush/drush dependencies\n\nDevelopment-related changes:\n\n* Add Golang integration tests in `/go-tests`\n* Run tests and security checks in GitHub Actions\n* Use Composer 2 in dev builds\n* Remove composer-bin-plugin\n* Update countries script for new CLDR format", + "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`)." } }, { From c1ad124aedec013677e99f005dd923d78e5236cf Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 4 Aug 2025 07:53:43 -0400 Subject: [PATCH 14/37] Fix Drush site URL when there is no app route (e.g. with Varnish) --- src/Service/Api.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Service/Api.php b/src/Service/Api.php index c98e4107d9..96c28e3af6 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1406,7 +1406,10 @@ public function getSiteUrl(Environment $environment, $appName, EnvironmentDeploy // Fall back to the public-url property. if ($environment->hasLink('public-url')) { - return $environment->getLink('public-url'); + $data = $environment->getData(); + if (!empty($data['_links']['public-url']['href'])) { + return $data['_links']['public-url']['href']; + } } return null; From 5dd0a3bef4756d8722cd6dfe124871b2f2218dec Mon Sep 17 00:00:00 2001 From: Ricardo Kirkner Date: Tue, 12 Aug 2025 10:41:39 -0300 Subject: [PATCH 15/37] Initial support for listing environment autoscaling settings (#1549) --- composer.json | 2 +- composer.lock | 18 +-- src/Application.php | 1 + .../AutoscalingSettingsGetCommand.php | 119 ++++++++++++++++++ src/Command/Domain/DomainCommandBase.php | 8 +- .../Integration/IntegrationCommandBase.php | 15 +-- .../Build/BuildResourcesSetCommand.php | 2 +- src/Service/Api.php | 111 +++++++++++++++- 8 files changed, 247 insertions(+), 29 deletions(-) create mode 100644 src/Command/Autoscaling/AutoscalingSettingsGetCommand.php diff --git a/composer.json b/composer.json index 1711e547e1..e26230b40e 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "guzzlehttp/guzzle": "^5.3", "guzzlehttp/ringphp": "^1.1", "platformsh/console-form": ">=0.0.37 <2.0", - "platformsh/client": ">=0.89.0 <2.0", + "platformsh/client": ">=0.90.0 <2.0", "symfony/console": "^3.0 >=3.2", "symfony/yaml": "^3.0 || ^2.6", "symfony/finder": "^3.0", diff --git a/composer.lock b/composer.lock index 1211ab1801..392d451b66 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "550801f8c7ed5c5253406b5404429b0d", + "content-hash": "c051bb5371e52679c448b7768ceef028", "packages": [ { "name": "cocur/slugify", @@ -921,16 +921,16 @@ }, { "name": "platformsh/client", - "version": "0.89.0", + "version": "0.90.0", "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "9ca1adcf3f7a1e996d30a564f995577644cfb4df" + "reference": "1bdce4b84912c563145a45120912186516f0857c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/9ca1adcf3f7a1e996d30a564f995577644cfb4df", - "reference": "9ca1adcf3f7a1e996d30a564f995577644cfb4df", + "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/1bdce4b84912c563145a45120912186516f0857c", + "reference": "1bdce4b84912c563145a45120912186516f0857c", "shasum": "" }, "require": { @@ -962,9 +962,9 @@ "description": "Platform.sh API client", "support": { "issues": "https://github.com/platformsh/platformsh-client-php/issues", - "source": "https://github.com/platformsh/platformsh-client-php/tree/0.89.0" + "source": "https://github.com/platformsh/platformsh-client-php/tree/0.90.0" }, - "time": "2025-07-31T18:59:15+00:00" + "time": "2025-08-07T16:00:58+00:00" }, { "name": "platformsh/console-form", @@ -4060,14 +4060,14 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=5.5.9", "ext-json": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "5.5.9" }, diff --git a/src/Application.php b/src/Application.php index 7d08ea9149..da2806a01e 100644 --- a/src/Application.php +++ b/src/Application.php @@ -125,6 +125,7 @@ protected function getCommands() $commands[] = new Command\Auth\ApiTokenLoginCommand(); $commands[] = new Command\Auth\BrowserLoginCommand(); $commands[] = new Command\Auth\VerifyPhoneNumberCommand(); + $commands[] = new Command\Autoscaling\AutoscalingSettingsGetCommand(); $commands[] = new Command\BlueGreen\BlueGreenConcludeCommand(); $commands[] = new Command\BlueGreen\BlueGreenDeployCommand(); $commands[] = new Command\BlueGreen\BlueGreenEnableCommand(); diff --git a/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php b/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php new file mode 100644 index 0000000000..c94c3bb6bc --- /dev/null +++ b/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php @@ -0,0 +1,119 @@ + 'App or service', + 'metric' => 'Metric', + 'direction' => 'Direction', + 'threshold' => 'Threshold (%)', + 'duration' => 'Duration (s)', + 'cooldown' => 'Cooldown (s)', + 'enabled' => 'Enabled', + 'instance_count' => 'Instances', + 'min_instances' => 'Minimum instances', + 'max_instances' => 'Maximum instances', + ]; + protected $defaultColumns = ['service', 'metric', 'direction', 'threshold', 'duration', 'enabled', 'instance_count']; + + protected function configure() + { + $this->setName('autoscaling:get') + ->setAliases(['autoscaling']) + ->setDescription('View the autoscaling configuration of apps and workers on an environment'); + $this->addProjectOption()->addEnvironmentOption(); + Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->validateInput($input); + if (!$this->api()->supportsAutoscaling($this->getSelectedProject())) { + $this->stdErr->writeln(sprintf('The autoscaling API is not enabled for the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + return 1; + } + + $environment = $this->getSelectedEnvironment(); + + try { + $deployment = $this->api()->getCurrentDeployment($environment); + } catch (EnvironmentStateException $e) { + if ($environment->status === 'inactive') { + $this->stdErr->writeln(sprintf('The environment %s is not active so autoscaling configuration cannot be read.', $this->api()->getEnvironmentLabel($environment, 'comment'))); + return 1; + } + throw $e; + } + + $autoscalingSettings = $this->api()->getAutoscalingSettings($environment)->getData(); + + $services = array_merge($deployment->webapps, $deployment->workers); + if (empty($services)) { + $this->stdErr->writeln('No apps or workers found.'); + return 1; + } + + /** @var Table $table */ + $table = $this->getService('table'); + + if (!$table->formatIsMachineReadable()) { + $this->stdErr->writeln(sprintf('Autoscaling configuration for the project %s, environment %s:', $this->api()->getProjectLabel($this->getSelectedProject()), $this->api()->getEnvironmentLabel($environment))); + } + + /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ + $formatter = $this->getService('property_formatter'); + + $empty = $table->formatIsMachineReadable() ? '' : 'not set'; + + $rows = []; + foreach ($autoscalingSettings['services'] as $service => $settings) { + $row = [ + 'service' => $service, + 'metric' => $empty, + 'direction' => $empty, + 'threshold' => $empty, + 'duration' => $empty, + 'enabled' => $empty, + 'cooldown' => $empty, + 'min_instances' => $empty, + 'max_instances' => $empty, + 'instance_count' => $empty, + ]; + + foreach ($settings['triggers'] as $metric => $conditions) { + $row['metric'] = $metric; + foreach ($conditions as $direction => $condition) { + if ($direction == "enabled") { + $row['enabled'] = $formatter->format($condition, 'enabled'); + continue; + } + $row['direction'] = $direction; + $row['threshold'] = sprintf('%.1f%%', $condition['threshold']); + $row['duration'] = $condition['duration']; + + $row['cooldown'] = $settings['scale_cooldown'][$direction]; + $row['min_instances'] = $settings['instances']['min']; + $row['max_instances'] = $settings['instances']['max']; + + $properties = $services[$service]->getProperties(); + $row['instance_count'] = isset($properties['instance_count']) ? $formatter->format($properties['instance_count'], 'instance_count') : '1'; + + + $rows[] = $row; + } + } + } + + $table->render($rows, $this->tableHeader, $this->defaultColumns); + + return 0; + } +} diff --git a/src/Command/Domain/DomainCommandBase.php b/src/Command/Domain/DomainCommandBase.php index 70406adc04..4dbe25d690 100644 --- a/src/Command/Domain/DomainCommandBase.php +++ b/src/Command/Domain/DomainCommandBase.php @@ -225,11 +225,7 @@ protected function handleApiException(ClientException $e, Project $project) */ protected function supportsNonProductionDomains(Project $project) { - static $cache = []; - if (!isset($cache[$project->id])) { - $capabilities = $project->getCapabilities(); - $cache[$project->id] = !empty($capabilities->custom_domains['enabled']); - } - return $cache[$project->id]; + $capabilities = $this->api()->getProjectCapabilities($project); + return !empty($capabilities->custom_domains['enabled']); } } diff --git a/src/Command/Integration/IntegrationCommandBase.php b/src/Command/Integration/IntegrationCommandBase.php index 4db35decc3..a19e113e58 100644 --- a/src/Command/Integration/IntegrationCommandBase.php +++ b/src/Command/Integration/IntegrationCommandBase.php @@ -157,14 +157,11 @@ protected function postProcessValues(array $values, Integration $integration = n * * @return array */ - private function selectedProjectIntegrations() + private function selectedProjectIntegrationCapabilities() { - static $cache = []; - $project = $this->getSelectedProject(); - if (!isset($cache[$project->id])) { - $cache[$project->id] = $project->hasLink('#capabilities') ? $project->getCapabilities()->integrations : []; - } - return $cache[$project->id]; + return $this->api() + ->getProjectCapabilities($this->getSelectedProject()) + ->integrations; } /** @@ -203,7 +200,7 @@ private function getFields() } // If the type is supported, check if it is available on the project. if ($this->hasSelectedProject()) { - $integrations = $this->selectedProjectIntegrations(); + $integrations = $this->selectedProjectIntegrationCapabilities(); if (!empty($integrations['enabled']) && empty($integrations['config'][$value]['enabled'])) { return "The integration type '$value' is not available on this project."; } @@ -212,7 +209,7 @@ private function getFields() }, 'optionsCallback' => function () use ($allSupportedTypes) { if ($this->hasSelectedProject()) { - $integrations = $this->selectedProjectIntegrations(); + $integrations = $this->selectedProjectIntegrationCapabilities(); if (!empty($integrations['enabled']) && !empty($integrations['config'])) { return array_filter($allSupportedTypes, function ($type) use ($integrations) { return !empty($integrations['config'][$type]['enabled']); diff --git a/src/Command/Resources/Build/BuildResourcesSetCommand.php b/src/Command/Resources/Build/BuildResourcesSetCommand.php index 3b49c28977..6297f63e32 100644 --- a/src/Command/Resources/Build/BuildResourcesSetCommand.php +++ b/src/Command/Resources/Build/BuildResourcesSetCommand.php @@ -29,7 +29,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $project = $this->getSelectedProject(); - $capabilities = $project->getCapabilities(); + $capabilities = $this->api()->getProjectCapabilities($project); $capability = $capabilities->getProperty('build_resources', false); $maxCpu = $capability ? $capability['max_cpu'] : null; diff --git a/src/Service/Api.php b/src/Service/Api.php index 96c28e3af6..47a6ae78ee 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1680,14 +1680,64 @@ public function supportsSizingApi(Project $project, EnvironmentDeployment $deplo if (isset($deployment->project_info['settings'])) { return !empty($deployment->project_info['settings']['sizing_api_enabled']); } + $settings = $this->getProjectSettings($project); + return !empty($settings['sizing_api_enabled']); + } + + /** + * Checks if a project supports the Autoscaling API. + * + * @param Project $project + * @return bool + */ + public function supportsAutoscaling(Project $project) + { + $capabilities = $this->getProjectCapabilities($project); + return !empty($capabilities->autoscaling['enabled']); + } + + /** + * Returns project settings. + * + * Settings are cached between calls unless a refresh is forced. + * + * @param bool $refresh + * + * @return array + */ + public function getProjectSettings(Project $project, $refresh = false) + { $cacheKey = 'project-settings:' . $project->id; $cachedSettings = $this->cache->fetch($cacheKey); - if (!empty($cachedSettings['sizing_api_enabled'])) { - return true; + if (!empty($cachedSettings) && !$refresh) { + return $cachedSettings; } + $settings = $this->getHttpClient()->get($project->getUri() . '/settings')->json(); $this->cache->save($cacheKey, $settings, $this->config->get('api.projects_ttl')); - return !empty($settings['sizing_api_enabled']); + return $settings; + } + + /** + * Returns project capabilities. + * + * Capabilities are cached between calls unless a refresh is forced. + * + * @param bool $refresh + * + * @return \Platformsh\Client\Model\Project\Capabilities + */ + public function getProjectCapabilities(Project $project, $refresh = false) + { + $cacheKey = 'project-capabilities:' . $project->id; + $cachedCapabilities = $this->cache->fetch($cacheKey); + if (!empty($cachedCapabilities) && !$refresh) { + return $cachedCapabilities; + } + + $capabilities = $project->getCapabilities(); + $this->cache->save($cacheKey, $capabilities, $this->config->get('api.projects_ttl')); + return $capabilities; } /** @@ -1706,4 +1756,59 @@ public function getCodeSourceIntegration(Project $project) } return null; } + + /** + * Returns the URL to view autoscaling settings for the selected environment. + * + * @param Environment $environment + * @param bool $manage + * + * @return string|false + * The url to the autoscaling settings endpoint or false on failure. + */ + public function getAutoscalingSettingsLink(Environment $environment, bool $manage = false) + { + $link = "#autoscaling"; + if ($manage === true) { + $link = "#manage-autoscaling"; + } + + $environmentData = $environment->getData(); + if (!isset($environmentData['_links'][$link])) { + $this->stdErr->writeln(\sprintf('Autoscaling support is not currently available on the environment: %s', $this->getEnvironmentLabel($environment, 'error'))); + + return false; + } + if (!isset($environmentData['_links'][$link]['href'])) { + $this->stdErr->writeln(\sprintf('Unable to find autoscaling URLs for the environment: %s', $this->getEnvironmentLabel($environment, 'error'))); + + return false; + } + + return $environmentData['_links'][$link]['href']; + } + + /** + * Returns the autoscaling settings for the selected environment. + * + * @param Environment $environment + * + * @return \Platformsh\Client\Model\AutoscalingSettings + */ + public function getAutoscalingSettings(Environment $environment) + { + if (!$this->getAutoscalingSettingsLink($environment)) { + throw new EnvironmentStateException('Autoscaling support is not currently available on the environment', $environment); + } + + try { + $settings = $environment->getAutoscalingSettings(); + } catch (EnvironmentStateException $e) { + if ($e->getEnvironment()->status === 'inactive') { + throw new EnvironmentStateException('The environment is inactive', $e->getEnvironment()); + } + throw $e; + } + return $settings; + } } From 1fc6d1d47addf501865af919576adfc8859945b9 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 14 Aug 2025 00:09:08 -0400 Subject: [PATCH 16/37] Fix command recommendation when there are staged activities --- src/Command/Environment/EnvironmentActivateCommand.php | 2 +- src/Command/Environment/EnvironmentSshCommand.php | 2 +- src/Service/ActivityMonitor.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Command/Environment/EnvironmentActivateCommand.php b/src/Command/Environment/EnvironmentActivateCommand.php index 85636380e6..df72174504 100644 --- a/src/Command/Environment/EnvironmentActivateCommand.php +++ b/src/Command/Environment/EnvironmentActivateCommand.php @@ -93,7 +93,7 @@ protected function activateMultiple(array $environments, InputInterface $input, ]); } $output->writeln(sprintf( - 'To resume the environment, run: %s environment:resume', + 'To resume the environment, run: %s env:resume', $this->config()->get('application.executable') )); $count--; diff --git a/src/Command/Environment/EnvironmentSshCommand.php b/src/Command/Environment/EnvironmentSshCommand.php index cfe5b6d27b..15acc1051b 100644 --- a/src/Command/Environment/EnvironmentSshCommand.php +++ b/src/Command/Environment/EnvironmentSshCommand.php @@ -70,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf('The environment %s is paused, so an SSH connection is not possible.', $this->api()->getEnvironmentLabel($e->getEnvironment(), 'error'))); if ($this->config()->isCommandEnabled('environment:resume')) { $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('Resume the environment by running: %s environment:resume -e %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($environment->id))); + $this->stdErr->writeln(sprintf('Resume the environment by running: %s env:resume -e %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($environment->id))); } return 1; } diff --git a/src/Service/ActivityMonitor.php b/src/Service/ActivityMonitor.php index b06f755935..70b15e53ef 100644 --- a/src/Service/ActivityMonitor.php +++ b/src/Service/ActivityMonitor.php @@ -542,7 +542,7 @@ private function printResult(Activity $activity, $logOnFailure = false) } if ($activity->state === Activity::STATE_STAGED) { - $stdErr->writeln(sprintf('To deploy staged changes, run: %s deploy', + $stdErr->writeln(sprintf('To deploy staged changes, run: %s env:deploy', $this->config->get('application.executable'))); } From 3d57db7fd28b985f70fe4afef0cc1e4dfba8f4d4 Mon Sep 17 00:00:00 2001 From: Ricardo Kirkner Date: Wed, 20 Aug 2025 11:23:55 -0300 Subject: [PATCH 17/37] Changing instance count on services where autoscaling is enabled is not allowed (#1553) * Changing instance count on services where autoscaling is enabled is not allowed * Move instance count autoscaling validation earlier --------- Co-authored-by: Ricardo Kirkner Co-authored-by: Patrick Dawkins --- src/Command/Resources/ResourcesSetCommand.php | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Command/Resources/ResourcesSetCommand.php b/src/Command/Resources/ResourcesSetCommand.php index e014e4fe0e..595c42aa70 100644 --- a/src/Command/Resources/ResourcesSetCommand.php +++ b/src/Command/Resources/ResourcesSetCommand.php @@ -97,14 +97,21 @@ protected function execute(InputInterface $input, OutputInterface $output) $instanceLimit = $projectInfo['capabilities']['instance_limit']; } + // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. + $autoscalingSettings = $this->api()->getAutoscalingSettings($environment)->getData(); + $autoscalingEnabled = []; + foreach ($autoscalingSettings['services'] as $service => $serviceSettings) { + $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); + } + // Validate the --size option. list($givenSizes, $errored) = $this->parseSetting($input, 'size', $services, function ($v, $serviceName, $service) use ($nextDeployment) { return $this->validateProfileSize($v, $serviceName, $service, $nextDeployment); }); // Validate the --count option. - list($givenCounts, $countErrored) = $this->parseSetting($input, 'count', $services, function ($v, $serviceName, $service) use ($instanceLimit) { - return $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit); + list($givenCounts, $countErrored) = $this->parseSetting($input, 'count', $services, function ($v, $serviceName, $service) use ($instanceLimit, $autoscalingEnabled) { + return $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit, !empty($autoscalingEnabled[$serviceName])); }); $errored = $errored || $countErrored; @@ -230,7 +237,8 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Set the instance count. - if (!$service instanceof Service) { // a Service instance count cannot be changed + // This is not applicable to a Service, and unavailable when autoscaling is enabled. + if (!$service instanceof Service && empty($autoscalingEnabled[$name])) { if (isset($givenCounts[$name])) { $instanceCount = $givenCounts[$name]; if ($instanceCount !== $properties['instance_count'] && !($instanceCount === 1 && !isset($properties['instance_count']))) { @@ -240,7 +248,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $ensureHeader(); $default = $properties['instance_count'] ?: 1; $instanceCount = $questionHelper->askInput('Enter the number of instances', $default, [], function ($v) use ($name, $service, $instanceLimit) { - return $this->validateInstanceCount($v, $name, $service, $instanceLimit); + return $this->validateInstanceCount($v, $name, $service, $instanceLimit, false); }); if ($instanceCount !== $properties['instance_count']) { $updates[$group][$name]['instance_count'] = $instanceCount; @@ -468,16 +476,20 @@ protected function typeName($service) * @param string $serviceName * @param Service|WebApp|Worker $service * @param int|null $limit + * @param bool $autoscalingEnabled * * @throws InvalidArgumentException * * @return int */ - protected function validateInstanceCount($value, $serviceName, $service, $limit) + protected function validateInstanceCount($value, $serviceName, $service, $limit, $autoscalingEnabled) { if ($service instanceof Service) { throw new InvalidArgumentException(sprintf('The instance count of the service %s cannot be changed.', $serviceName)); } + if ($autoscalingEnabled) { + throw new InvalidArgumentException(sprintf('The instance count of the %s %s cannot be changed when autoscaling is enabled.', $this->typeName($service), $serviceName)); + } $count = (int) $value; if ($count != $value || $value <= 0) { throw new InvalidArgumentException(sprintf('Invalid instance count %s: it must be an integer greater than 0.', $value)); From edf0fe6677df7ce4f67f808da30ca40757dc77c0 Mon Sep 17 00:00:00 2001 From: Ricardo Kirkner Date: Thu, 28 Aug 2025 10:21:09 -0300 Subject: [PATCH 18/37] fix: let getAutoscalingSettings return empty settings when autoscaling is not available (#1558) Co-authored-by: Ricardo Kirkner --- src/Service/Api.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Service/Api.php b/src/Service/Api.php index 47a6ae78ee..7dfd6ec5da 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1797,10 +1797,6 @@ public function getAutoscalingSettingsLink(Environment $environment, bool $manag */ public function getAutoscalingSettings(Environment $environment) { - if (!$this->getAutoscalingSettingsLink($environment)) { - throw new EnvironmentStateException('Autoscaling support is not currently available on the environment', $environment); - } - try { $settings = $environment->getAutoscalingSettings(); } catch (EnvironmentStateException $e) { From 795cd723a2de6f92756fe9b101de2f2d45c3f496 Mon Sep 17 00:00:00 2001 From: vitolkachova Date: Fri, 29 Aug 2025 13:45:55 +0200 Subject: [PATCH 19/37] Allow strategy for environment deploy command (#1559) --- .../Environment/EnvironmentDeployCommand.php | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Command/Environment/EnvironmentDeployCommand.php b/src/Command/Environment/EnvironmentDeployCommand.php index e8e0f6a719..6cc09fd13e 100644 --- a/src/Command/Environment/EnvironmentDeployCommand.php +++ b/src/Command/Environment/EnvironmentDeployCommand.php @@ -6,6 +6,7 @@ use Platformsh\Cli\Service\ActivityMonitor; use Platformsh\Client\Model\Activity; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class EnvironmentDeployCommand extends CommandBase @@ -23,7 +24,9 @@ protected function configure() $this ->setName('environment:deploy') ->setAliases(['env:deploy']) - ->setDescription('Deploy an environment\'s staged changes'); + ->setDescription('Deploy an environment\'s staged changes') + ->addOption('strategy', 's', InputOption::VALUE_REQUIRED, + 'The deployment strategy, stopstart (default, restart with a shutdown) or rolling (zero downtime)'); $this->addProjectOption() ->addEnvironmentOption(); $this->addWaitOptions(); @@ -81,11 +84,36 @@ protected function execute(InputInterface $input, OutputInterface $output) /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ $questionHelper = $this->getService('question_helper'); + + $strategy = $input->getOption('strategy'); + $can_rolling_deploy = $environment->getProperty('can_rolling_deploy', false); + if (is_null($strategy)) { + if ($can_rolling_deploy) { + $options = [ + 'stopstart' => 'Restart with a shutdown', + 'rolling' => 'Zero downtime deployment' + ]; + $strategy = $questionHelper->chooseAssoc($options, 'Choose the deployment strategy: ', 'stopstart'); + } else { + $strategy = 'stopstart'; + } + } else { + if (!in_array($strategy, ['stopstart', 'rolling'])) { + $this->stdErr->writeln('The chosen strategy is not available for this environment.'); + return 1; + } elseif (!$can_rolling_deploy && $strategy === 'rolling') { + $this->stdErr->writeln('The chosen strategy is not available for this environment.'); + return 1; + } + } + if ($strategy === 'rolling') { + $this->stdErr->writeln('Please make sure the changes from above are not affecting the state of the services.'); + } if (!$questionHelper->confirm('Are you sure you want to continue?')) { return 1; } - $result = $environment->runOperation('deploy'); + $result = $environment->runOperation('deploy', 'POST', ['strategy' => $strategy]); if ($this->shouldWait($input)) { /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ From 5b9701d581fff642f69fd398c6132ff22ee5af70 Mon Sep 17 00:00:00 2001 From: Ricardo Kirkner Date: Fri, 29 Aug 2025 13:27:02 -0300 Subject: [PATCH 20/37] Include a badge next to the service name when autoscaling is enabled (#1557) --- src/Command/Resources/ResourcesGetCommand.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Command/Resources/ResourcesGetCommand.php b/src/Command/Resources/ResourcesGetCommand.php index c3d03e5295..c75eed538b 100644 --- a/src/Command/Resources/ResourcesGetCommand.php +++ b/src/Command/Resources/ResourcesGetCommand.php @@ -74,6 +74,13 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } + // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. + $autoscalingSettings = $this->api()->getAutoscalingSettings($environment)->getData(); + $autoscalingEnabled = []; + foreach ($autoscalingSettings['services'] as $service => $serviceSettings) { + $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); + } + /** @var Table $table */ $table = $this->getService('table'); @@ -91,8 +98,14 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows = []; $cpuTypeOption = $input->getOption('cpu-type'); + $autoscalingIndicator = '(A)'; + $hasAutoscalingIndicator = false; foreach ($services as $name => $service) { $properties = $service->getProperties(); + if (!$table->formatIsMachineReadable() && !empty($autoscalingEnabled[$name])) { + $name .= ' ' . $autoscalingIndicator; + $hasAutoscalingIndicator = true; + } $row = [ 'service' => $name, 'type' => $formatter->format($service->type, 'service_type'), @@ -145,6 +158,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $table->render($rows, $this->tableHeader, $this->defaultColumns); if (!$table->formatIsMachineReadable()) { + if ($hasAutoscalingIndicator) { + $this->stdErr->writeln($autoscalingIndicator . ' - Indicates that the service has autoscaling enabled'); + } + $executable = $this->config()->get('application.executable'); $isOriginalCommand = $input instanceof ArgvInput; if ($isOriginalCommand) { From 8ac92eeab84fa567d01d14890f936a9aa6c91b37 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 8 Sep 2025 10:50:56 +0100 Subject: [PATCH 21/37] Treat upsun and platfomsh vendors as equivalent in project list from 2025-09-23 --- src/Service/Api.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Service/Api.php b/src/Service/Api.php index 7dfd6ec5da..b85528d96e 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -650,6 +650,10 @@ private function matchesVendorFilter($filters, BasicProjectInfo $project) if (in_array($project->vendor, (array) $filters)) { return true; } + // Show projects with the "upsun" vendor under the "platformsh" filter, from September 23rd 2025. + if ($project->vendor === 'upsun' && in_array('platformsh', (array) $filters)) { + return time() > 1758596400; // 2025-09-23T03:00:00Z // 5am CEST + } return false; } From b03cfd1dacdf0915de58ff21590454dc3d7fc0be Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 9 Sep 2025 10:25:27 +0100 Subject: [PATCH 22/37] Avoid writing to stdout when opening a URL --- src/Service/Url.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Service/Url.php b/src/Service/Url.php index c5eb48c8e1..5944582fe6 100644 --- a/src/Service/Url.php +++ b/src/Service/Url.php @@ -90,17 +90,12 @@ public function openUrl($url, $print = true) if ($open && ($browser = $this->getBrowser($browserOption))) { if (OsUtil::isWindows() && $browser === 'start') { // The start command needs an extra (title) argument. - $command = $browser . ' "" ' . escapeshellarg($url); + $args = [$browser, '', $url]; } else { - $command = $browser . ' ' . escapeshellarg($url); - - // Suppress the browser's STDERR unless in very verbose mode. - // Chrome, at least, outputs alarming and unnecessary messages. - if (!$this->stdErr->isVeryVerbose()) { - $command .= ' 2>/dev/null'; - } + $args = [$browser, $url]; } - $success = $this->shell->executeSimple($command) === 0; + + $success = $this->shell->execute($args) === true; } // Print the URL. From e290d42fc8a5709eba0a62d888a638e1816a4515 Mon Sep 17 00:00:00 2001 From: Quentin Dawans Date: Tue, 9 Sep 2025 11:29:28 +0200 Subject: [PATCH 23/37] Add e:deploy as alias to environment:deploy (#1560) --- src/Command/Environment/EnvironmentDeployCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/Environment/EnvironmentDeployCommand.php b/src/Command/Environment/EnvironmentDeployCommand.php index 6cc09fd13e..59ff5695a4 100644 --- a/src/Command/Environment/EnvironmentDeployCommand.php +++ b/src/Command/Environment/EnvironmentDeployCommand.php @@ -23,7 +23,7 @@ protected function configure() { $this ->setName('environment:deploy') - ->setAliases(['env:deploy']) + ->setAliases(['e:deploy','env:deploy']) ->setDescription('Deploy an environment\'s staged changes') ->addOption('strategy', 's', InputOption::VALUE_REQUIRED, 'The deployment strategy, stopstart (default, restart with a shutdown) or rolling (zero downtime)'); From e4f9c581999103af0abca8ccc126df454dfebe20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Haracewiat?= <5583430+mharacewiat@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:23:18 +0200 Subject: [PATCH 24/37] feat: enable otlp integration (#1543) Co-authored-by: Patrick Dawkins --- src/Command/Integration/IntegrationCommandBase.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Command/Integration/IntegrationCommandBase.php b/src/Command/Integration/IntegrationCommandBase.php index a19e113e58..d6390ece7f 100644 --- a/src/Command/Integration/IntegrationCommandBase.php +++ b/src/Command/Integration/IntegrationCommandBase.php @@ -185,6 +185,7 @@ private function getFields() 'splunk', 'sumologic', 'syslog', + 'otlp', ]; return [ @@ -418,6 +419,7 @@ private function getFields() 'sumologic', 'splunk', 'webhook', + 'otlp', ]], 'description' => 'The URL or API endpoint for the integration', ]), @@ -594,6 +596,7 @@ private function getFields() 'splunk', 'sumologic', 'syslog', + 'otlp', ]], 'description' => 'Whether HTTPS certificate verification should be enabled (recommended)', 'questionLine' => 'Should HTTPS certificate verification be enabled (recommended)', @@ -603,7 +606,10 @@ private function getFields() ]), 'headers' => new ArrayField('HTTP header', [ 'optionName' => 'header', - 'conditions' => ['type' => 'httplog'], + 'conditions' => ['type' => [ + 'httplog', + 'otlp', + ]], 'description' => 'HTTP header(s) to use in POST requests. Separate names and values with a colon (:).', 'required' => false, // Override the default split pattern (which splits a comma-separated From 23f5c02731d81ea10833550905a2f4be71473be7 Mon Sep 17 00:00:00 2001 From: Paul Gilzow Date: Tue, 9 Sep 2025 06:45:03 -0400 Subject: [PATCH 25/37] Update embedded docs links to new permalink structure (#1555) --- README.md | 2 +- config.yaml | 8 ++++---- dist/installer.php | 4 ++-- src/Local/ApplicationFinder.php | 2 +- src/Service/Relationships.php | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 83b0e930f1..3d7293d258 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ There are two ways to authenticate: 1. The recommended way is `platform login`, which lets you log in via a web browser, including via third-party providers such as Google, GitHub, GitLab and Bitbucket. -2. If using a browser is not possible, use an [API token](https://docs.platform.sh/gettingstarted/cli/api-tokens.html). +2. If using a browser is not possible, use an [API token](https://docs.upsun.com/anchors/fixed/cli/api-token/). An interactive command is available for this: `platform auth:api-token-login` diff --git a/config.yaml b/config.yaml index a11f805336..c98e35fa59 100644 --- a/config.yaml +++ b/config.yaml @@ -38,9 +38,9 @@ service: pricing_url: 'https://platform.sh/pricing' - activity_type_list_url: 'https://docs.platform.sh/integrations/activity/reference.html#type' + activity_type_list_url: 'https://docs.upsun.com/anchors/fixed/integrations/activity-scripts/type/' - runtime_operations_help_url: 'https://docs.platform.sh/create-apps/runtime-operations.html' + runtime_operations_help_url: 'https://docs.upsun.com/anchors/fixed/app/runtime-operations/' api: base_url: 'https://api.platform.sh' @@ -66,7 +66,7 @@ detection: migrate: prompt: true - docs_url: https://docs.platform.sh/administration/cli.html + docs_url: https://docs.upsun.com/anchors/fixed/cli/ warnings: non_production_domains_msg: | @@ -74,7 +74,7 @@ warnings: If you're an Enterprise or Elite customer, contact support to enable the feature. Otherwise contact sales first to upgrade your plan. - See: https://docs.platform.sh/overview/get-support.html + See: https://docs.upsun.com/anchors/fixed/get-support/ guaranteed_resources_msg: | You have chosen to allocate guaranteed resources for app(s) and/or service(s). diff --git a/dist/installer.php b/dist/installer.php index 7618236405..ac7bff01b5 100644 --- a/dist/installer.php +++ b/dist/installer.php @@ -6,7 +6,7 @@ * * @deprecated * The CLI no longer requires a local PHP installation or this installer. - * See https://docs.platform.sh/administration/cli.html + * See https://docs.upsun.com/anchors/fixed/cli/ * * This script will check requirements, download the CLI, move it into place, * and run the self:install command (to set up the PATH and autocompletion). @@ -80,7 +80,7 @@ public function __construct(array $args = []) { 'userAgent' => 'platformsh-cli', 'serviceEnvPrefix' => 'PLATFORM_', 'migratePrompt' => true, - 'migrateDocsUrl' => 'https://docs.platform.sh/administration/cli.html', + 'migrateDocsUrl' => 'https://docs.upsun.com/anchors/fixed/cli/', )/* END_CONFIG */; $required = ['envPrefix', 'manifestUrl', 'configDir', 'executable', 'cliName']; diff --git a/src/Local/ApplicationFinder.php b/src/Local/ApplicationFinder.php index 44134220e6..da084a3722 100644 --- a/src/Local/ApplicationFinder.php +++ b/src/Local/ApplicationFinder.php @@ -113,7 +113,7 @@ private function findGroupedApplications($directory) /** * Returns the root directory explicitly configured for the application, if any. * - * @see https://docs.platform.sh/configuration/app/multi-app.html#explicit-sourceroot + * @see https://docs.upsun.com/anchors/fixed/app/multiple/source/root/ * * @param array $appConfig * @param string $sourceDir diff --git a/src/Service/Relationships.php b/src/Service/Relationships.php index 9e18d8a693..4fdbe94661 100644 --- a/src/Service/Relationships.php +++ b/src/Service/Relationships.php @@ -397,8 +397,8 @@ public function buildUrl(array $instance) * is configured, all four service types default to having one schema named * "main". * - * See https://docs.platform.sh/add-services/postgresql.html - * and https://docs.platform.sh/add-services/mysql.html + * See https://docs.upsun.com/anchors/fixed/services/postgresql/ + * and https://docs.upsun.com/anchors/fixed/services/mysql/ * * @return string[] */ From 29892889f456e73dc08e5139a5908eb60a11ffe9 Mon Sep 17 00:00:00 2001 From: Muhammad Inam Date: Tue, 9 Sep 2025 19:05:51 +0530 Subject: [PATCH 26/37] Show confirmation message on env branch/activate for guaranteed resources (#1546) --- config-defaults.yaml | 3 + config.yaml | 3 + src/Command/CommandBase.php | 4 + .../EnvironmentActivateCommand.php | 8 ++ .../Environment/EnvironmentBranchCommand.php | 14 +++ .../Resources/ResourcesCommandBase.php | 88 ++++++----------- src/Command/Resources/ResourcesGetCommand.php | 6 +- src/Command/Resources/ResourcesSetCommand.php | 15 +-- .../Resources/ResourcesSizeListCommand.php | 8 +- src/Service/Api.php | 99 +++++++++++++++++++ 10 files changed, 174 insertions(+), 74 deletions(-) diff --git a/config-defaults.yaml b/config-defaults.yaml index 55f3072303..7443449d3c 100644 --- a/config-defaults.yaml +++ b/config-defaults.yaml @@ -399,6 +399,9 @@ warnings: # The message shown when configuring guaranteed resources. guaranteed_resources_msg: null + # The message shown when branching/activating an environment with resources init strategy parent. + guaranteed_resources_branch_msg: null + # Configuration for informational messages. messages: # A message about discounts where a region choice is displayed. diff --git a/config.yaml b/config.yaml index c98e35fa59..297195c3b0 100644 --- a/config.yaml +++ b/config.yaml @@ -81,3 +81,6 @@ warnings: This change may affect resource costs. See: https://upsun.com/pricing/ This process requires a redeployment of containers on their own host, which may take a few minutes to complete. + + guaranteed_resources_branch_msg: | + Guaranteed resources from the parent will be provisioned, impacting your bill. diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 0cb57243a8..a2a744b0e3 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -6,6 +6,7 @@ use Platformsh\Cli\Command\Self\SelfInstallCommand; use Platformsh\Cli\Console\ArrayArgument; use Platformsh\Cli\Console\HiddenInputOption; +use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\Event\EnvironmentsChangedEvent; use Platformsh\Cli\Event\LoginRequiredEvent; use Platformsh\Cli\Exception\LoginRequiredException; @@ -23,7 +24,10 @@ use Platformsh\Cli\Util\StringUtil; use Platformsh\Client\Exception\EnvironmentStateException; use Platformsh\Client\Model\BasicProjectInfo; +use Platformsh\Client\Model\Deployment\EnvironmentDeployment; +use Platformsh\Client\Model\Deployment\Service; use Platformsh\Client\Model\Deployment\WebApp; +use Platformsh\Client\Model\Deployment\Worker; use Platformsh\Client\Model\Environment; use Platformsh\Client\Model\Organization\Organization; use Platformsh\Client\Model\Project; diff --git a/src/Command/Environment/EnvironmentActivateCommand.php b/src/Command/Environment/EnvironmentActivateCommand.php index df72174504..54b3d7d8b0 100644 --- a/src/Command/Environment/EnvironmentActivateCommand.php +++ b/src/Command/Environment/EnvironmentActivateCommand.php @@ -112,7 +112,15 @@ protected function activateMultiple(array $environments, InputInterface $input, } continue; } + + $hasGuaranteedCPU = $this->api()->environmentHasGuaranteedCPU($environment); $question = "Are you sure you want to activate the environment " . $this->api()->getEnvironmentLabel($environment) . "?"; + if ($resourcesInit === 'parent' && $hasGuaranteedCPU && $this->config()->has('warnings.guaranteed_resources_branch_msg')) { + $this->stdErr->writeln(''); + $question = trim($this->config()->get('warnings.guaranteed_resources_branch_msg')) + . "\n\n" . $question; + } + if (!$questionHelper->confirm($question)) { continue; } diff --git a/src/Command/Environment/EnvironmentBranchCommand.php b/src/Command/Environment/EnvironmentBranchCommand.php index ef08ae2ae5..da0159b8ed 100644 --- a/src/Command/Environment/EnvironmentBranchCommand.php +++ b/src/Command/Environment/EnvironmentBranchCommand.php @@ -142,6 +142,19 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('Resource sizes will be inherited from the parent environment.'); } + $hasGuaranteedCPU = $this->api()->environmentHasGuaranteedCPU($parentEnvironment); + if ($resourcesInit === 'parent' && $hasGuaranteedCPU && $this->config()->has('warnings.guaranteed_resources_branch_msg')) { + /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ + $questionHelper = $this->getService('question_helper'); + $this->stdErr->writeln(''); + $questionText = trim($this->config()->get('warnings.guaranteed_resources_branch_msg')) + . "\n\n" . "Are you sure you want to continue?"; + + if (!$questionHelper->confirm($questionText)) { + return 1; + } + } + if ($dryRun) { $this->stdErr->writeln(''); if ($checkoutLocally) { @@ -164,6 +177,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($resourcesInit !== null) { $params['resources']['init'] = $resourcesInit; } + $result = $parentEnvironment->runOperation('branch', 'POST', $params); $activities = $result->getActivities(); diff --git a/src/Command/Resources/ResourcesCommandBase.php b/src/Command/Resources/ResourcesCommandBase.php index 73cdccfc92..77ef06223f 100644 --- a/src/Command/Resources/ResourcesCommandBase.php +++ b/src/Command/Resources/ResourcesCommandBase.php @@ -4,44 +4,19 @@ use Platformsh\Cli\Command\CommandBase; use Platformsh\Cli\Console\ArrayArgument; -use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\Util\Wildcard; -use Platformsh\Client\Exception\EnvironmentStateException; -use Platformsh\Client\Model\Deployment\EnvironmentDeployment; use Platformsh\Client\Model\Deployment\Service; use Platformsh\Client\Model\Deployment\WebApp; use Platformsh\Client\Model\Deployment\Worker; -use Platformsh\Client\Model\Environment; use Symfony\Component\Console\Input\InputInterface; class ResourcesCommandBase extends CommandBase { - private static $cachedNextDeployment = []; - public function isHidden() { return !$this->config()->get('api.sizing') || parent::isHidden(); } - /** - * Lists services in a deployment. - * - * @param EnvironmentDeployment $deployment - * - * @return array - * An array of services keyed by the service name. - */ - protected function allServices(EnvironmentDeployment $deployment) - { - $webapps = $deployment->webapps; - $workers = $deployment->workers; - $services = $deployment->services; - ksort($webapps, SORT_STRING|SORT_FLAG_CASE); - ksort($workers, SORT_STRING|SORT_FLAG_CASE); - ksort($services, SORT_STRING|SORT_FLAG_CASE); - return array_merge($webapps, $workers, $services); - } - /** * Checks whether a service needs a persistent disk. * @@ -57,34 +32,6 @@ protected function supportsDisk($service) return isset($service->getProperties()['resources']['minimum']['disk']); } - /** - * Loads the next environment deployment and caches it statically. - * - * The static cache means it can be reused while running a sub-command. - * - * @param Environment $environment - * @param bool $reset - * @return EnvironmentDeployment - */ - protected function loadNextDeployment(Environment $environment, $reset = false) - { - $cacheKey = $environment->project . ':' . $environment->id; - if (isset(self::$cachedNextDeployment[$cacheKey]) && !$reset) { - return self::$cachedNextDeployment[$cacheKey]; - } - $progress = new ProgressMessage($this->stdErr); - try { - $progress->show('Loading deployment information...'); - $next = $environment->getNextDeployment(); - if (!$next) { - throw new EnvironmentStateException('No next deployment found', $environment); - } - } finally { - $progress->done(); - } - return self::$cachedNextDeployment[$cacheKey] = $next; - } - /** * Filters a list of services according to the --service or --type options. * @@ -207,16 +154,37 @@ protected function formatCPU($unformatted) } /** - * Check if project supports guaranteed resources. + * Format CPU Type. * - * @param array $projectInfo + * @param array|null $sizeInfo * - * @return bool - * True if guaranteed CPU is supported, false otherwise. + * @return string */ - protected function supportsGuaranteedCPU(array $projectInfo) + protected function formatCPUType($sizeInfo) { - return !empty($projectInfo["settings"]["enable_guaranteed_resources"]) && - !empty($projectInfo["capabilities"]["guaranteed_resources"]["enabled"]); + $size = $sizeInfo ? $sizeInfo['cpu'] : null; + if ($size === null) { + return ""; + } + + return sprintf('(%s)', $sizeInfo['cpu_type']); + } + + /** + * Sort container profiles by size. + * + * @param array $profiles + * + * @return array + */ + protected function sortContainerProfiles(array $profiles) + { + foreach ($profiles as &$profile) { + uasort($profile, function($a, $b) { + return $a['cpu'] == $b['cpu'] ? 0 : ($a['cpu'] > $b['cpu'] ? 1 : -1); + }); + } + + return $profiles; } } diff --git a/src/Command/Resources/ResourcesGetCommand.php b/src/Command/Resources/ResourcesGetCommand.php index c75eed538b..15b1c4728e 100644 --- a/src/Command/Resources/ResourcesGetCommand.php +++ b/src/Command/Resources/ResourcesGetCommand.php @@ -54,7 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $environment = $this->getSelectedEnvironment(); try { - $nextDeployment = $this->loadNextDeployment($environment); + $nextDeployment = $this->api()->loadNextDeployment($environment); } catch (EnvironmentStateException $e) { if ($environment->status === 'inactive') { $this->stdErr->writeln(sprintf('The environment %s is not active so resource configuration cannot be read.', $this->api()->getEnvironmentLabel($environment, 'comment'))); @@ -63,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output) throw $e; } - $services = $this->allServices($nextDeployment); + $services = $this->api()->allServices($nextDeployment); if (empty($services)) { $this->stdErr->writeln('No apps or services found'); return 1; @@ -94,7 +94,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $empty = $table->formatIsMachineReadable() ? '' : 'not set'; $notApplicable = $table->formatIsMachineReadable() ? '' : 'N/A'; - $containerProfiles = $nextDeployment->container_profiles; + $containerProfiles = $this->sortContainerProfiles($nextDeployment->container_profiles); $rows = []; $cpuTypeOption = $input->getOption('cpu-type'); diff --git a/src/Command/Resources/ResourcesSetCommand.php b/src/Command/Resources/ResourcesSetCommand.php index 595c42aa70..f64444f464 100644 --- a/src/Command/Resources/ResourcesSetCommand.php +++ b/src/Command/Resources/ResourcesSetCommand.php @@ -76,7 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $environment = $this->getSelectedEnvironment(); try { - $nextDeployment = $this->loadNextDeployment($environment); + $nextDeployment = $this->api()->loadNextDeployment($environment); } catch (EnvironmentStateException $e) { if ($environment->status === 'inactive') { $this->stdErr->writeln(sprintf('The environment %s is not active so resources cannot be configured.', $this->api()->getEnvironmentLabel($environment, 'comment'))); @@ -85,7 +85,7 @@ protected function execute(InputInterface $input, OutputInterface $output) throw $e; } - $services = $this->allServices($nextDeployment); + $services = $this->api()->allServices($nextDeployment); if (empty($services)) { $this->stdErr->writeln('No apps or services found'); return 1; @@ -135,10 +135,10 @@ protected function execute(InputInterface $input, OutputInterface $output) /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ $questionHelper = $this->getService('question_helper'); - $containerProfiles = $nextDeployment->container_profiles; + $containerProfiles = $this->sortContainerProfiles($nextDeployment->container_profiles); // Remove guaranteed profiles if project does not support it. - $supportsGuaranteedCPU = $this->supportsGuaranteedCPU($nextDeployment->project_info); + $supportsGuaranteedCPU = $this->api()->supportsGuaranteedCPU($nextDeployment->project_info); foreach ($containerProfiles as $profileName => $profile) { foreach ($profile as $sizeName => $sizeInfo) { if (!$supportsGuaranteedCPU && $sizeInfo['cpu_type'] == 'guaranteed') { @@ -203,13 +203,14 @@ protected function execute(InputInterface $input, OutputInterface $output) || (isset($properties['resources']['minimum']['memory']) && $sizeInfo['memory'] < $properties['resources']['minimum']['memory'])) { continue; } - $description = sprintf('CPU %s, memory %s MB', $sizeInfo['cpu'], $sizeInfo['memory']); + $description = sprintf('CPU %s, memory %s MB (%s)', $sizeInfo['cpu'], $sizeInfo['memory'], $sizeInfo['cpu_type']); if (isset($properties['resources']['profile_size']) && $profileSize == $properties['resources']['profile_size']) { $description .= ' (current)'; } elseif ($defaultOption !== null && $defaultOption === $profileSize) { $description .= ' (default)'; } + $options[$profileSize] = $description; } @@ -410,8 +411,8 @@ private function summarizeChangesPerService($name, $service, array $updates, arr $newProperties = array_replace_recursive($properties, $updates); $newSizeInfo = $this->sizeInfo($newProperties, $containerProfiles); $this->stdErr->writeln(' CPU: ' . $this->formatChange( - $this->formatCPU($sizeInfo ? $sizeInfo['cpu'] : null), - $this->formatCPU($newSizeInfo['cpu']) + $this->formatCPU($sizeInfo ? $sizeInfo['cpu'] : null) . ' ' . $this->formatCPUType($sizeInfo), + $this->formatCPU($newSizeInfo['cpu']) . ' '. $this->formatCPUType($newSizeInfo) )); $this->stdErr->writeln(' Memory: ' . $this->formatChange( $sizeInfo ? $sizeInfo['memory'] : null, diff --git a/src/Command/Resources/ResourcesSizeListCommand.php b/src/Command/Resources/ResourcesSizeListCommand.php index 5e13f1d48d..2c128486c2 100644 --- a/src/Command/Resources/ResourcesSizeListCommand.php +++ b/src/Command/Resources/ResourcesSizeListCommand.php @@ -38,9 +38,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } $environment = $this->getSelectedEnvironment(); - $nextDeployment = $this->loadNextDeployment($environment); + $nextDeployment = $this->api()->loadNextDeployment($environment); - $services = $this->allServices($nextDeployment); + $services = $this->api()->allServices($nextDeployment); if (empty($services)) { $this->stdErr->writeln('No apps or services found'); return 1; @@ -50,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $servicesByProfile[$service->container_profile][] = $name; } - $containerProfiles = $nextDeployment->container_profiles; + $containerProfiles = $this->sortContainerProfiles($nextDeployment->container_profiles); if ($serviceOption = $input->getOption('service')) { if (!isset($services[$serviceOption])) { @@ -84,7 +84,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $table = $this->getService('table'); $rows = []; - $supportsGuaranteedCPU = $this->supportsGuaranteedCPU($nextDeployment->project_info); + $supportsGuaranteedCPU = $this->api()->supportsGuaranteedCPU($nextDeployment->project_info); $defaultColumns = $this->defaultColumns; if ($supportsGuaranteedCPU) { $defaultColumns[] = 'cpu_type'; diff --git a/src/Service/Api.php b/src/Service/Api.php index b85528d96e..fed07e24f0 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -10,6 +10,7 @@ use GuzzleHttp\Event\ErrorEvent; use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Message\ResponseInterface; +use Platformsh\Cli\Console\ProgressMessage; use Platformsh\Cli\CredentialHelper\KeyringUnavailableException; use Platformsh\Cli\CredentialHelper\Manager; use Platformsh\Cli\CredentialHelper\SessionStorage as CredentialHelperStorage; @@ -25,6 +26,9 @@ use Platformsh\Client\Exception\EnvironmentStateException; use Platformsh\Client\Model\BasicProjectInfo; use Platformsh\Client\Model\Deployment\EnvironmentDeployment; +use Platformsh\Client\Model\Deployment\Service; +use Platformsh\Client\Model\Deployment\WebApp; +use Platformsh\Client\Model\Deployment\Worker; use Platformsh\Client\Model\Environment; use Platformsh\Client\Model\EnvironmentType; use Platformsh\Client\Model\Organization\Member; @@ -74,6 +78,13 @@ class Api /** @var FileLock */ private $fileLock; + /** + * Cached next deployment. + * + * @var array + */ + private static $cachedNextDeployment = []; + /** * The library's API client object. * @@ -1811,4 +1822,92 @@ public function getAutoscalingSettings(Environment $environment) } return $settings; } + + /** + * Loads the next environment deployment and caches it statically. + * + * The static cache means it can be reused while running a sub-command. + * + * @param Environment $environment + * @param bool $reset + * @return EnvironmentDeployment + */ + public function loadNextDeployment(Environment $environment, $reset = false) + { + $cacheKey = $environment->project . ':' . $environment->id; + if (isset(self::$cachedNextDeployment[$cacheKey]) && !$reset) { + return self::$cachedNextDeployment[$cacheKey]; + } + $progress = new ProgressMessage($this->stdErr); + try { + $progress->show('Loading deployment information...'); + $next = $environment->getNextDeployment(); + if (!$next) { + throw new EnvironmentStateException('No next deployment found', $environment); + } + } finally { + $progress->done(); + } + return self::$cachedNextDeployment[$cacheKey] = $next; + } + + /** + * Lists services in a deployment. + * + * @param EnvironmentDeployment $deployment + * + * @return array + * An array of services keyed by the service name. + */ + public function allServices(EnvironmentDeployment $deployment) + { + $webapps = $deployment->webapps; + $workers = $deployment->workers; + $services = $deployment->services; + ksort($webapps, SORT_STRING|SORT_FLAG_CASE); + ksort($workers, SORT_STRING|SORT_FLAG_CASE); + ksort($services, SORT_STRING|SORT_FLAG_CASE); + return array_merge($webapps, $workers, $services); + } + + /** + * Check if project supports guaranteed resources. + * + * @param array $projectInfo + * + * @return bool + * True if guaranteed CPU is supported, false otherwise. + */ + public function supportsGuaranteedCPU(array $projectInfo) + { + return !empty($projectInfo["settings"]["enable_guaranteed_resources"]) && + !empty($projectInfo["capabilities"]["guaranteed_resources"]["enabled"]); + } + + /** + * Check if environment has guaranteed CPU. + * + * @param \Platformsh\Client\Model\Environment $environment + * + * @return bool + */ + public function environmentHasGuaranteedCPU(Environment $environment) + { + $deployment = $this->getCurrentDeployment($environment); + if ($this->supportsGuaranteedCPU($deployment->project_info)) { + $containerProfiles = $deployment->container_profiles; + $services = $this->allServices($deployment); + foreach ($services as $service) { + $properties = $service->getProperties(); + if (isset($properties['container_profile']) && isset($containerProfiles[$properties['container_profile']][$properties['resources']['profile_size']])) { + $profileInfo = $containerProfiles[$properties['container_profile']][$properties['resources']['profile_size']]; + if (isset($profileInfo['cpu_type']) && $profileInfo['cpu_type'] == 'guaranteed') { + return true; + } + } + } + } + + return false; + } } From d0a09e336e0f99b69b67103f96b3c3085986328b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 9 Sep 2025 16:24:33 +0100 Subject: [PATCH 27/37] Fix PHP < 7 compatibility (for this branch) in Api.php (This method is not yet used, but it will be used by a pending PR) --- src/Service/Api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/Api.php b/src/Service/Api.php index fed07e24f0..fb040ea92c 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1781,7 +1781,7 @@ public function getCodeSourceIntegration(Project $project) * @return string|false * The url to the autoscaling settings endpoint or false on failure. */ - public function getAutoscalingSettingsLink(Environment $environment, bool $manage = false) + public function getAutoscalingSettingsLink(Environment $environment, $manage = false) { $link = "#autoscaling"; if ($manage === true) { From d258bbe178cacbb461df9bf184bda2a536c4ac4a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 10 Sep 2025 16:42:35 +0100 Subject: [PATCH 28/37] Release v4.25.0 --- dist/manifest.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dist/manifest.json b/dist/manifest.json index b967470b5f..2b79af8325 100644 --- a/dist/manifest.json +++ b/dist/manifest.json @@ -1,10 +1,10 @@ [ { - "version": "4.24.0", - "sha1": "d20b52a3a251ff0cc29205c788858ccdda0c4d3d", - "sha256": "ccc78282dff95541bd92363d484fc4839e4fd9c881d2edaf449b4c4de14e3abf", + "version": "4.25.0", + "sha1": "b37c28bceb278a76fc5fdd9aadc466cef9e0e4e8", + "sha256": "ef1c284e3793e7906efdc7a21fe1abbe3a23dd982b412cb2a5b821a22669a5c2", "name": "platform.phar", - "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.24.0/platform.phar", + "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.25.0/platform.phar", "php": { "min": "5.5.9" }, @@ -74,7 +74,8 @@ "4.21.1": "* Re-allow output from \"ssh-cert:load --refresh-only\", now that quiet mode works (#1491)\n This partially reverts 29a92850a2ce8a4f583e23bba75dcf1fdaad44c3\n* After branching, only set the upstream if the remote exists\n* Skip the cache when updating an org via the org:info command\n* Do not require SSH permission to list mounts\n* Handle old envs with outdated \"has_remote\" in the env:delete command", "4.22.0": "New features:\n\n* Add `--init-repo` option for the `create` (`project:create`) command, as a \n shortcut for creating a project from code in a public repository.\n* Before creating a project, check the organization's permissions. This uses the \n `can-create` API endpoint, which supports more potential action prompts than \n the previous version (which only supported phone verification), e.g. to create \n a support ticket or to update the organization's billing details.\n\nOther changes:\n\n* Make `redis` and `ssh` args consistent; allow multiple args in the `redis` command.\n* Fix error after creating a new organization from a project directory.\n* Fix the project list cache not clearing automatically.", "4.23.0": "New features:\n\n* Add Valkey support\n* Refresh the token and retry requests on a 401 error in :curl commands\n\nOther functional changes:\n\n* Rename \"web\" command to \"console\" (\"web\" remains as an alias)\n* Show \"(legacy)\" in the version output, if not wrapped.\n* Fix the console URL in the project:create (create) command\n* Fix duplication of commands in Markdown-formatted list\n* installer: fix use of GitHub credentials on container where available\n* Update composer/ca-bundle and drush/drush dependencies\n\nDevelopment-related changes:\n\n* Add Golang integration tests in `/go-tests`\n* Run tests and security checks in GitHub Actions\n* Use Composer 2 in dev builds\n* Remove composer-bin-plugin\n* Update countries script for new CLDR format", - "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.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)." } }, { From 176dc4c29c9fc49e71ee2c136f65adb953145de6 Mon Sep 17 00:00:00 2001 From: vitolkachova Date: Fri, 12 Sep 2025 13:35:23 +0200 Subject: [PATCH 29/37] Add 'deploy' alias for env:deploy --- src/Command/Environment/EnvironmentDeployCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/Environment/EnvironmentDeployCommand.php b/src/Command/Environment/EnvironmentDeployCommand.php index 59ff5695a4..566eda0039 100644 --- a/src/Command/Environment/EnvironmentDeployCommand.php +++ b/src/Command/Environment/EnvironmentDeployCommand.php @@ -23,7 +23,7 @@ protected function configure() { $this ->setName('environment:deploy') - ->setAliases(['e:deploy','env:deploy']) + ->setAliases(['deploy','e:deploy','env:deploy']) ->setDescription('Deploy an environment\'s staged changes') ->addOption('strategy', 's', InputOption::VALUE_REQUIRED, 'The deployment strategy, stopstart (default, restart with a shutdown) or rolling (zero downtime)'); From abb87d2941d9b015398d960672d7cf4eb6733d7d Mon Sep 17 00:00:00 2001 From: Ricardo Kirkner Date: Fri, 12 Sep 2025 11:05:12 -0300 Subject: [PATCH 30/37] Add autoscaling:set command to configure CPU-based autoscaling --- src/Application.php | 1 + .../AutoscalingSettingsGetCommand.php | 109 ++- .../AutoscalingSettingsSetCommand.php | 888 ++++++++++++++++++ src/Command/Resources/ResourcesGetCommand.php | 10 +- src/Command/Resources/ResourcesSetCommand.php | 14 +- src/Service/Api.php | 55 +- 6 files changed, 1007 insertions(+), 70 deletions(-) create mode 100644 src/Command/Autoscaling/AutoscalingSettingsSetCommand.php diff --git a/src/Application.php b/src/Application.php index da2806a01e..3296732640 100644 --- a/src/Application.php +++ b/src/Application.php @@ -126,6 +126,7 @@ protected function getCommands() $commands[] = new Command\Auth\BrowserLoginCommand(); $commands[] = new Command\Auth\VerifyPhoneNumberCommand(); $commands[] = new Command\Autoscaling\AutoscalingSettingsGetCommand(); + $commands[] = new Command\Autoscaling\AutoscalingSettingsSetCommand(); $commands[] = new Command\BlueGreen\BlueGreenConcludeCommand(); $commands[] = new Command\BlueGreen\BlueGreenDeployCommand(); $commands[] = new Command\BlueGreen\BlueGreenEnableCommand(); diff --git a/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php b/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php index c94c3bb6bc..f967c6d259 100644 --- a/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php +++ b/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php @@ -5,6 +5,7 @@ use Platformsh\Cli\Command\CommandBase; use Platformsh\Cli\Service\Table; use Platformsh\Client\Exception\EnvironmentStateException; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -53,7 +54,12 @@ protected function execute(InputInterface $input, OutputInterface $output) throw $e; } - $autoscalingSettings = $this->api()->getAutoscalingSettings($environment)->getData(); + $autoscalingSettings = $this->api()->getAutoscalingSettings($environment); + if (!$autoscalingSettings) { + $this->stdErr->writeln(\sprintf('Autoscaling support is not currently available on the environment: %s', $this->api()->getEnvironmentLabel($environment, 'error'))); + return 1; + } + $autoscalingSettings = $autoscalingSettings->getData(); $services = array_merge($deployment->webapps, $deployment->workers); if (empty($services)) { @@ -61,58 +67,67 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var Table $table */ - $table = $this->getService('table'); + if (!empty($autoscalingSettings['services'])) { + /** @var Table $table */ + $table = $this->getService('table'); - if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln(sprintf('Autoscaling configuration for the project %s, environment %s:', $this->api()->getProjectLabel($this->getSelectedProject()), $this->api()->getEnvironmentLabel($environment))); - } + if (!$table->formatIsMachineReadable()) { + $this->stdErr->writeln(sprintf('Autoscaling configuration for the project %s, environment %s:', $this->api()->getProjectLabel($this->getSelectedProject()), $this->api()->getEnvironmentLabel($environment))); + } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - - $empty = $table->formatIsMachineReadable() ? '' : 'not set'; - - $rows = []; - foreach ($autoscalingSettings['services'] as $service => $settings) { - $row = [ - 'service' => $service, - 'metric' => $empty, - 'direction' => $empty, - 'threshold' => $empty, - 'duration' => $empty, - 'enabled' => $empty, - 'cooldown' => $empty, - 'min_instances' => $empty, - 'max_instances' => $empty, - 'instance_count' => $empty, - ]; - - foreach ($settings['triggers'] as $metric => $conditions) { - $row['metric'] = $metric; - foreach ($conditions as $direction => $condition) { - if ($direction == "enabled") { - $row['enabled'] = $formatter->format($condition, 'enabled'); - continue; + /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ + $formatter = $this->getService('property_formatter'); + + $empty = $table->formatIsMachineReadable() ? '' : 'not set'; + + $rows = []; + foreach ($autoscalingSettings['services'] as $service => $settings) { + $row = [ + 'service' => $service, + 'metric' => $empty, + 'direction' => $empty, + 'threshold' => $empty, + 'duration' => $empty, + 'enabled' => $empty, + 'cooldown' => $empty, + 'min_instances' => $empty, + 'max_instances' => $empty, + 'instance_count' => $empty, + ]; + + foreach ($settings['triggers'] as $metric => $conditions) { + $row['metric'] = $metric; + foreach ($conditions as $direction => $condition) { + if ($direction == "enabled") { + $row['enabled'] = $formatter->format($condition, 'enabled'); + continue; + } + $row['direction'] = $direction; + $row['threshold'] = sprintf('%.1f%%', $condition['threshold']); + $row['duration'] = $condition['duration']; + + $row['cooldown'] = $settings['scale_cooldown'][$direction]; + $row['min_instances'] = $settings['instances']['min']; + $row['max_instances'] = $settings['instances']['max']; + + $properties = $services[$service]->getProperties(); + $row['instance_count'] = isset($properties['instance_count']) ? $formatter->format($properties['instance_count'], 'instance_count') : '1'; + + + $rows[] = $row; } - $row['direction'] = $direction; - $row['threshold'] = sprintf('%.1f%%', $condition['threshold']); - $row['duration'] = $condition['duration']; - - $row['cooldown'] = $settings['scale_cooldown'][$direction]; - $row['min_instances'] = $settings['instances']['min']; - $row['max_instances'] = $settings['instances']['max']; - - $properties = $services[$service]->getProperties(); - $row['instance_count'] = isset($properties['instance_count']) ? $formatter->format($properties['instance_count'], 'instance_count') : '1'; - - - $rows[] = $row; } } - } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $table->render($rows, $this->tableHeader, $this->defaultColumns); + } else { + $this->stdErr->writeln(sprintf('No autoscaling configuration found for the project %s, environment %s.', $this->api()->getProjectLabel($this->getSelectedProject()), $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()->get('application.executable'))); + } + } return 0; } diff --git a/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php b/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php new file mode 100644 index 0000000000..48fc6c5b1d --- /dev/null +++ b/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php @@ -0,0 +1,888 @@ +setName('autoscaling:set') + ->setDescription('Set the autoscaling configuration of apps or workers in an environment') + ->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Name of the app or worker 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') + ->addOption('duration-up', null, InputOption::VALUE_REQUIRED, 'Duration over which metric is evaluated against threshold for scaling up') + ->addOption('cooldown-up', null, InputOption::VALUE_REQUIRED, 'Duration to wait before attempting to further scale up after a scaling event') + ->addOption('threshold-down', null, InputOption::VALUE_REQUIRED, 'Threshold under which service will be scaled down') + ->addOption('duration-down', null, InputOption::VALUE_REQUIRED, 'Duration over which metric is evaluated against threshold for scaling down') + ->addOption('cooldown-down', null, InputOption::VALUE_REQUIRED, 'Duration to wait before attempting to further scale down after a scaling event') + ->addOption('instances-min', null, InputOption::VALUE_REQUIRED, 'Minimum number of instances that will be scaled down to') + ->addOption('instances-max', null, InputOption::VALUE_REQUIRED, 'Maximum number of instances that will be scaled up to') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show the changes that would be made, without changing anything') + ->addProjectOption() + ->addEnvironmentOption(); + + $helpLines = [ + 'Configure automatic scaling for apps or workers in an environment.', + '', + sprintf('You can also configure resources statically by running: %s resources:set', $this->config()->get('application.executable')) + ]; + if ($this->config()->has('service.autoscaling_help_url')) { + $helpLines[] = ''; + $helpLines[] = 'For more information on autoscaling, see: ' . $this->config()->get('service.autoscaling_help_url') . ''; + } + $this->setHelp(implode("\n", $helpLines)); + + $this->addExample('Enable autoscaling for an application using the default configuration', '--service app --metric cpu'); + $this->addExample('Enable autoscaling for an application specifying a minimum of instances at all times', '--service app --metric cpu --instances-min 3'); + $this->addExample('Enable autoscaling for an application specifying a maximum of instances at most', '--service app --metric cpu --instances-max 5'); + $this->addExample('Disable autoscaling on cpu for an application', '--service app --metric cpu --enabled false'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->validateInput($input); + if (!$this->api()->supportsAutoscaling($this->getSelectedProject())) { + $this->stdErr->writeln(sprintf('The autoscaling API is not enabled for the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + return 1; + } + + $environment = $this->getSelectedEnvironment(); + + try { + $deployment = $this->api()->getCurrentDeployment($environment); + } catch (EnvironmentStateException $e) { + if ($environment->status === 'inactive') { + $this->stdErr->writeln(sprintf('The environment %s is not active so autoscaling configuration cannot be set.', $this->api()->getEnvironmentLabel($environment, 'comment'))); + return 1; + } + throw $e; + } + + if (!$this->api()->getAutoscalingSettingsLink($environment, true)) { + throw new EnvironmentStateException('Managing autoscaling settings is not currently available', $environment); + } + + $autoscalingSettings = $this->api()->getAutoscalingSettings($environment); + if (!$autoscalingSettings) { + throw new EnvironmentStateException('Managing autoscaling settings is not currently available', $environment); + } + $autoscalingSettings = $autoscalingSettings->getData(); + + $services = array_merge($deployment->webapps, $deployment->workers); + if (empty($services)) { + $this->stdErr->writeln('No apps or workers found.'); + return 1; + } + + // Get autoscaling default values + $defaults = $autoscalingSettings['defaults']; + + // Validate the --service option. + $service = $input->getOption('service'); + if ($service !== null) { + $service = $this->validateService($service, $services); + } + + $supportedMetrics = $this->getSupportedMetrics($defaults); + + // Validate the --metric option. + $metric = $input->getOption('metric'); + if ($metric !== null) { + $metric = $this->validateMetric($metric, $supportedMetrics); + } + + // Validate the --enabled option. + $enabled = $input->getOption('enabled'); + if ($enabled !== null) { + $enabled = $this->validateBoolean($enabled); + } + + // Validate the --*-up options. + $thresholdUp = $input->getOption('threshold-up'); + if ($thresholdUp !== null) { + $thresholdUp = $this->validateThreshold($thresholdUp, 'threshold-up'); + } + $durationUp = $input->getOption('duration-up'); + if ($durationUp !== null) { + $durationUp = $this->validateDuration($durationUp); + } + $cooldownUp = $input->getOption('cooldown-up'); + if ($cooldownUp !== null) { + $cooldownUp = $this->validateDuration($cooldownUp); + } + + // Validate the --*-down options. + $thresholdDown = $input->getOption('threshold-down'); + if ($thresholdDown !== null) { + $thresholdDown = $this->validateThreshold($thresholdDown, 'threshold-down'); + } + $durationDown = $input->getOption('duration-down'); + if ($durationDown !== null) { + $durationDown = $this->validateDuration($durationDown); + } + $cooldownDown = $input->getOption('cooldown-down'); + if ($cooldownDown !== null) { + $cooldownDown = $this->validateDuration($cooldownDown); + } + + // Validate the --instances-* options. + $instanceLimit = $defaults['instances']['max']; + $instancesMin = $input->getOption('instances-min'); + if ($instancesMin !== null) { + $instancesMin = $this->validateInstanceCount($instancesMin, $instanceLimit, 'instances-min'); + } + $instancesMax = $input->getOption('instances-max'); + if ($instancesMax !== null) { + $instancesMax = $this->validateInstanceCount($instancesMax, $instanceLimit, 'instances-max'); + } + + // Show current autoscaling settings + if (($exitCode = $this->runOtherCommand('autoscaling:get', [ + '--project' => $environment->project, + '--environment' => $environment->id, + ], $this->stdErr)) !== 0) { + return $exitCode; + } + + $this->stdErr->writeln(''); + + /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ + $questionHelper = $this->getService('question_helper'); + + // Check if we should show the interactive form + $hasAnyOptions = $service !== null + || $thresholdUp !== null + || $durationUp !== null + || $cooldownUp !== null + || $thresholdDown !== null + || $durationDown !== null + || $cooldownDown !== null + || $instancesMin !== null + || $instancesMax !== null; + + $showInteractiveForm = $input->isInteractive() && !$hasAnyOptions; + + $updates = []; + + if ($showInteractiveForm) { + // Interactive mode: let user select services and configure them + $serviceNames = array_keys($services); + + if ($service === null) { + // Ask user to select services to configure + $default = $serviceNames[0]; + $text = 'Enter a number to choose an app or worker:' . "\n" . 'Default: ' . $default . ''; + $selectedService = $questionHelper->choose($serviceNames, $text, 0); + $service = $serviceNames[$selectedService]; + } + + // Get autoscaling current values for selected service + $currentServiceSettings = $autoscalingSettings['services'][$service]; + + $this->stdErr->writeln('' . ucfirst($this->typeName($services[$service])) . ': ' . $service . ''); + $this->stdErr->writeln(''); + + if ($metric === null) { + // Ask for metric name + $choices = $supportedMetrics; + $default = $choices[0]; + $text = 'Which metric should be used for autoscaling?' . "\n" . 'Default: ' . $default . ''; + $choice = $questionHelper->choose($choices, $text, 0, false); + $metric = $choices[$choice]; + } + + $this->handleScalingSettings($questionHelper, 'up', $service, $metric, $currentServiceSettings, $defaults, $thresholdUp, $durationUp, $cooldownUp, $updates); + $this->handleScalingSettings($questionHelper, 'down', $service, $metric, $currentServiceSettings, $defaults, $thresholdDown, $durationDown, $cooldownDown, $updates); + $this->handleInstanceSettings($questionHelper, $service, $currentServiceSettings, $instanceLimit, $instancesMin, $instancesMax, $updates); + + // Assume autoscaling should be enabled when showing interactive form + if ($enabled === null) { + $enabled = true; + } + // Only mark 'enabled' as an updated field if it is changing + if ($currentServiceSettings['enabled'] !== $enabled) { + $updates[$service]['enabled'] = $enabled; + } + + if (!empty($updates[$service])) { + // since we have some changes, inject the metric name for them + $updates[$service]['metric'] = $metric; + } + + } else { + // Non-interactive mode + if ($service === null) { + $this->stdErr->writeln('The --service option is required when not running interactively.'); + return 1; + } + + if ($thresholdUp !== null) { + $updates[$service]['threshold-up'] = $thresholdUp; + } + + if ($durationUp !== null) { + $updates[$service]['duration-up'] = $durationUp; + } + + if ($cooldownUp !== null) { + $updates[$service]['cooldown-up'] = $cooldownUp; + } + + if ($thresholdDown !== null) { + $updates[$service]['threshold-down'] = $thresholdDown; + } + + if ($durationDown !== null) { + $updates[$service]['duration-down'] = $durationDown; + } + + if ($cooldownDown !== null) { + $updates[$service]['cooldown-down'] = $cooldownDown; + } + + if ($instancesMin !== null) { + $updates[$service]['instances-min'] = $instancesMin; + } + + if ($instancesMax !== null) { + $updates[$service]['instances-max'] = $instancesMax; + } + + // Only mark 'enabled' as an updated field if it was explicitly set and is changing + $currentServiceSettings = $autoscalingSettings['services'][$service]; + if ($enabled !== null && $currentServiceSettings['enabled'] !== $enabled) { + $updates[$service]['enabled'] = $enabled; + } + + if (!empty($updates[$service])) { + $metric = $this->validateMetric($metric, $supportedMetrics); + // since we have some changes, inject the metric name for them + $updates[$service]['metric'] = $metric; + } + + } + + if (empty($updates)) { + $this->stdErr->writeln('No autoscaling changes were provided: nothing to update'); + return 0; + } + + $this->summarizeChanges($updates, $autoscalingSettings['services']); + + $this->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); + + if ($input->getOption('dry-run')) { + return 0; + } + + $this->stdErr->writeln(''); + + $questionText = 'Are you sure you want to continue?'; + if (!$questionHelper->confirm($questionText)) { + return 1; + } + + $this->stdErr->writeln(''); + $this->stdErr->writeln('Setting the autoscaling configuration on the environment ' . $this->api()->getEnvironmentLabel($environment)); + + $data = $this->makeAutoscalingSettingsData($updates); + $this->api()->setAutoscalingSettings($environment, $data); + + return 0; + } + + /** + * Handle scaling settings (up/down) for interactive mode. + * + * @param \Platformsh\Cli\Service\QuestionHelper $questionHelper + * @param string $direction Either 'up' or 'down' + * @param string $service Service name + * @param string $metric Metric name + * @param array|null $currentServiceSettings Current settings for the service + * @param array $defaults Default autoscaling settings + * @param float|null $threshold Threshold value (passed by reference) + * @param int|null $duration Duration value (passed by reference) + * @param int|null $cooldown Cooldown value (passed by reference) + * @param array $updates Updates array (passed by reference) + * + * @return void + */ + private function handleScalingSettings($questionHelper, $direction, $service, $metric, $currentServiceSettings, array $defaults, &$threshold, &$duration, &$cooldown, array &$updates) + { + if ($threshold === null || $duration === null || $cooldown === null) { + $text = 'Settings for scaling ' . $direction . ''; + $this->stdErr->writeln($text); + $this->stdErr->writeln(''); + + $threshold = $this->askForSetting($questionHelper, $threshold, 'Enter the threshold (%)', + isset($currentServiceSettings['triggers'][$metric][$direction]['threshold']) ? $currentServiceSettings['triggers'][$metric][$direction]['threshold'] : null, + $defaults['triggers'][$metric][$direction]['threshold'], + function($value) { return $this->validateThreshold($value); }, + $service, 'threshold-' . $direction, $updates + ); + + $duration = $this->askForDurationSetting($questionHelper, $duration, 'Enter the duration of the evaluation period', + isset($currentServiceSettings['triggers'][$metric][$direction]['duration']) ? $currentServiceSettings['triggers'][$metric][$direction]['duration'] : null, + $defaults['triggers'][$metric][$direction]['duration'], + $service, 'duration-' . $direction, $updates + ); + + $cooldown = $this->askForDurationSetting($questionHelper, $cooldown, 'Enter the duration of the cool-down period', + isset($currentServiceSettings['scale_cooldown'][$direction]) ? $currentServiceSettings['scale_cooldown'][$direction] : null, + $defaults['scale_cooldown'][$direction], + $service, 'cooldown-' . $direction, $updates + ); + } + } + + /** + * Handle instance settings for interactive mode. + * + * @param \Platformsh\Cli\Service\QuestionHelper $questionHelper + * @param string $service Service name + * @param array|null $currentServiceSettings Current settings for the service + * @param int $instanceLimit Maximum allowed instances + * @param int|null $instancesMin Minimum instances (passed by reference) + * @param int|null $instancesMax Maximum instances (passed by reference) + * @param array $updates Updates array (passed by reference) + * + * @return void + */ + private function handleInstanceSettings($questionHelper, $service, $currentServiceSettings, $instanceLimit, &$instancesMin, &$instancesMax, array &$updates) + { + $instancesMin = $this->askForSetting($questionHelper, $instancesMin, 'Enter the minimum number of instances', + isset($currentServiceSettings['instances']['min']) ? $currentServiceSettings['instances']['min'] : null, + 1, + function($value) use ($instanceLimit) { return $this->validateInstanceCount($value, $instanceLimit); }, + $service, 'instances-min', $updates + ); + + $instancesMax = $this->askForSetting($questionHelper, $instancesMax, 'Enter the maximum number of instances', + isset($currentServiceSettings['instances']['max']) ? $currentServiceSettings['instances']['max'] : null, + $instanceLimit, + function($value) use ($instanceLimit) { return $this->validateInstanceCount($value, $instanceLimit); }, + $service, 'instances-max', $updates + ); + } + + /** + * Generic method to ask for a setting value. + * + * @param \Platformsh\Cli\Service\QuestionHelper $questionHelper + * @param mixed $currentValue Current value (null if not set) + * @param string $prompt Prompt text for user input + * @param mixed $existingValue Existing value from current settings + * @param mixed $defaultValue Default value to use if no existing value + * @param callable $validator Function to validate user input + * @param string $service Service name + * @param string $updateKey Key to use in updates array + * @param array $updates Updates array (passed by reference) + * + * @return mixed The validated value + */ + private function askForSetting($questionHelper, $currentValue, $prompt, $existingValue, $defaultValue, callable $validator, $service, $updateKey, array &$updates) + { + if ($currentValue === null) { + $default = isset($existingValue) ? $existingValue : $defaultValue; + $newValue = $questionHelper->askInput($prompt, $default, [], $validator); + $this->stdErr->writeln(''); + if ($newValue !== $existingValue) { + $updates[$service][$updateKey] = $newValue; + } + return $newValue; + } else { + $updates[$service][$updateKey] = $currentValue; + return $currentValue; + } + } + + /** + * Specialized method to ask for duration settings with choices. + * + * @param \Platformsh\Cli\Service\QuestionHelper $questionHelper + * @param int|null $currentValue Current duration value (null if not set) + * @param string $prompt Prompt text for user input + * @param int|null $existingValue Existing duration from current settings + * @param int $defaultValue Default duration value + * @param string $service Service name + * @param string $updateKey Key to use in updates array + * @param array $updates Updates array (passed by reference) + * + * @return int The validated duration value + */ + private function askForDurationSetting($questionHelper, $currentValue, $prompt, $existingValue, $defaultValue, $service, $updateKey, array &$updates) + { + if ($currentValue === null) { + $choices = array_keys(self::$validDurations); + $default = isset($existingValue) ? $existingValue : $defaultValue; + $newValue = $questionHelper->askInput($prompt, $this->formatDuration($default), $choices, function ($v) { + return $this->validateDuration($v); + }); + $this->stdErr->writeln(''); + + if ($newValue !== $existingValue) { + $updates[$service][$updateKey] = $newValue; + } + return $newValue; + } else { + $updates[$service][$updateKey] = $currentValue; + return $currentValue; + } + } + + /** + * Build an AutoscalingSettings instance. + * + * @param array $updates + * + * @return array + */ + protected function makeAutoscalingSettingsData($updates) + { + $data = array('services' => []); + + foreach ($updates as $service => $serviceSettings) { + $serviceData = []; + if (isset($serviceSettings['metric'])) { + $triggerData = []; + if (isset($serviceSettings['threshold-up'])) { + $triggerData['up'] = array('threshold' => $serviceSettings['threshold-up']); + } + if (isset($serviceSettings['duration-up'])) { + if (isset($triggerData['up'])) { + $triggerData['up']['duration'] = $serviceSettings['duration-up']; + } else { + $triggerData['up'] = array('duration' => $serviceSettings['duration-up']); + } + } + if (isset($serviceSettings['threshold-down'])) { + $triggerData['down'] = array('threshold' => $serviceSettings['threshold-down']); + } + if (isset($serviceSettings['duration-down'])) { + if (isset($triggerData['down'])) { + $triggerData['down']['duration'] = $serviceSettings['duration-down']; + } else { + $triggerData['down'] = array('duration' => $serviceSettings['duration-down']); + } + } + if (isset($serviceSettings['enabled'])) { + $triggerData['enabled'] = $serviceSettings['enabled']; + } + $serviceData['triggers'] = array($serviceSettings['metric'] => $triggerData); + } + + if (isset($serviceSettings['cooldown-up']) || isset($serviceSettings['cooldown-down'])) { + $cooldownData = []; + if (isset($serviceSettings['cooldown-up'])) { + $cooldownData['up'] = $serviceSettings['cooldown-up']; + } + if (isset($serviceSettings['cooldown-down'])) { + $cooldownData['down'] = $serviceSettings['cooldown-down']; + } + $serviceData['scale_cooldown'] = $cooldownData; + } + + if (isset($serviceSettings['instances-min']) || isset($serviceSettings['instances-max'])) { + $instancesData = []; + if (isset($serviceSettings['instances-min'])) { + $instancesData['min'] = $serviceSettings['instances-min']; + } + if (isset($serviceSettings['instances-max'])) { + $instancesData['max'] = $serviceSettings['instances-max']; + } + $serviceData['instances'] = $instancesData; + } + + $data['services'][$service] = $serviceData; + } + + return $data; + } + + /** + * Summarizes all the changes that would be made. + * + * @param array $updates + * @param array $settings + * + * @return void + */ + private function summarizeChanges(array $updates, $settings) + { + $this->stdErr->writeln('Summary of changes:'); + foreach ($updates as $service => $serviceUpdates) { + $this->summarizeChangesPerService($service, isset($settings[$service]) ? $settings[$service] : null, $serviceUpdates); + } + } + + /** + * Summarizes changes per service. + * + * @param string $name The service name + * @param array|null $current + * @param array $updates + * + * @return void + */ + private function summarizeChangesPerService($name, $current, array $updates) + { + $this->stdErr->writeln(sprintf(' Service: %s', $name)); + + $metric = $updates['metric']; + $this->stdErr->writeln(sprintf(' Metric: %s', $metric)); + + $action = 'remain'; + $enabledText = $current['triggers'][$metric]['enabled'] ? 'enabled' : 'disabled'; + if (isset($updates['enabled'])) { + if ($current['triggers'][$metric]['enabled'] != $updates['enabled']) { + $action = 'become'; + $enabledText = $updates['enabled'] ? 'enabled' : 'disabled'; + } + } + $color = $enabledText == 'enabled' ? 'green' : 'yellow'; + $status = ''.$enabledText.''; + $this->stdErr->writeln(' Autoscaling will ' . $action . ': ' . $status); + + if (isset($updates['threshold-up']) || isset($updates['duration-up']) || isset($updates['cooldown-up'])) { + $this->stdErr->writeln(' Scaling up'); + + if (isset($updates['threshold-up'])) { + $this->stdErr->writeln(' Threshold: ' . $this->formatChange( + isset($current['triggers'][$metric]['up']) ? $current['triggers'][$metric]['up']['threshold'] : null, + $updates['threshold-up'], + '%' + )); + } + if (isset($updates['duration-up'])) { + $this->stdErr->writeln(' Duration: ' . $this->formatDurationChange( + isset($current['triggers'][$metric]['up']) ? $this->formatDuration($current['triggers'][$metric]['up']['duration']) : null, + $this->formatDuration($updates['duration-up']) + )); + } + if (isset($updates['cooldown-up'])) { + $this->stdErr->writeln(' Cooldown: ' . $this->formatDurationChange( + isset($current['scale_cooldown']) ? $this->formatDuration($current['scale_cooldown']['up']) : null, + $this->formatDuration($updates['cooldown-up']) + )); + } + } + + if (isset($updates['threshold-down']) || isset($updates['duration-down']) || isset($updates['cooldown-down'])) { + $this->stdErr->writeln(' Scaling down'); + + if (isset($updates['threshold-down'])) { + $this->stdErr->writeln(' Threshold: ' . $this->formatChange( + isset($current['triggers'][$metric]['down']) ? $current['triggers'][$metric]['down']['threshold'] : null, + $updates['threshold-down'], + '%' + )); + } + if (isset($updates['duration-down'])) { + $this->stdErr->writeln(' Duration: ' . $this->formatDurationChange( + isset($current['triggers'][$metric]['down']) ? $this->formatDuration($current['triggers'][$metric]['down']['duration']) : null, + $this->formatDuration($updates['duration-down']) + )); + } + if (isset($updates['cooldown-down'])) { + $this->stdErr->writeln(' Cooldown: ' . $this->formatDurationChange( + isset($current['scale_cooldown']) ? $this->formatDuration($current['scale_cooldown']['down']) : null, + $this->formatDuration($updates['cooldown-down']) + )); + } + } + + if (isset($updates['instances-min']) || isset($updates['instances-max'])) { + $this->stdErr->writeln(' Instances'); + if (isset($updates['instances-min'])) { + $this->stdErr->writeln(' Min: ' . $this->formatChange( + isset($current['instances']) ? $current['instances']['min'] : null, + $updates['instances-min'] + )); + } + + if (isset($updates['instances-max'])) { + $this->stdErr->writeln(' Max: ' . $this->formatChange( + isset($current['instances']) ? $current['instances']['max'] : null, + $updates['instances-max'] + )); + } + } + } + + /** + * Validates a service name. + * + * @param string $value + * @param array $services + * + * @throws InvalidArgumentException + * + * @return string + */ + protected function validateService($value, $services) + { + if (array_key_exists($value, $services)) { + return $value; + } + $serviceNames = array_keys($services); + throw new InvalidArgumentException(sprintf('Invalid service name %s. Available services: %s', $value, implode(', ', $serviceNames))); + } + + /** + * Return the names of supported metrics. + * + * @param array $defaults Autoscaling settings defaults + * + * @return array Supported metric names + */ + protected function getSupportedMetrics(array $defaults) + { + // TODO: change this once we properly support multiple metrics other than 'cpu' + // override supported metrics to only support cpu despite what the backend allows + return ['cpu']; + //return array_keys($defaults['triggers']); + } + + /** + * Validates a metric name. + * + * @param string $value Name of metric to validate + * @param array $metrics List of valid metric names + * + * @throws InvalidArgumentException + * + * @return string + */ + protected function validateMetric($value, $metrics) + { + if (array_key_exists($value, $metrics)) { + return $value; + } + throw new InvalidArgumentException(sprintf('Invalid metric name %s. Available metrics: %s', $value, implode(', ', $metrics))); + } + + /** + * Validates a boolean value. + * + * @param float|int|string|bool $value + * + * @throws InvalidArgumentException + * + * @return bool + */ + protected function validateBoolean($value) + { + if (is_bool($value)) { + return $value; + } + + switch ($value) { + case "true": + case "yes": + return true; + case "false": + case "no": + return false; + default: + throw new InvalidArgumentException(sprintf('Invalid value %s: must be one of true, yes, false, no', $value)); + } + } + + /** + * Validates a given threshold. + * + * @param float|int $value + * @param string $context + * + * @throws InvalidArgumentException + * + * @return float + */ + protected function validateThreshold($value, $context = '') + { + $threshold = (float) $value; + if ($threshold < 0) { + $message = sprintf('Invalid threshold %s: must be 0 or greater', $value); + if ($context) { + $message .= sprintf(' for %s', $context); + } + throw new InvalidArgumentException($message); + } + if ($threshold > 100) { + $message = sprintf('Invalid threshold %s: must be 100 or less', $value); + if ($context) { + $message .= sprintf(' for %s', $context); + } + throw new InvalidArgumentException($message); + } + return $threshold; + } + + private static $validDurations = [ + "1m" => 60, + "2m" => 120, + "5m" => 300, + "10m" => 600, + "30m" => 1800, + "60m" => 3600, + ]; + + /** + * Validates a given duration. + * + * @param string $value + * @param string $context + * + * @throws InvalidArgumentException + * + * @return int + */ + protected function validateDuration($value, $context = '') + { + if (!isset(self::$validDurations[$value])) { + $durations = array_keys(self::$validDurations); + $message = sprintf('Invalid duration %s: must be one of %s', $value, implode(', ', $durations)); + if ($context) { + $message .= sprintf(' for %s', $context); + } + throw new InvalidArgumentException($message); + } + return self::$validDurations[$value]; + } + + /** + * Returns the service type name for a service. + * + * @param Service|WebApp|Worker $service + * + * @return string + */ + protected function typeName($service) + { + if ($service instanceof WebApp) { + return 'app'; + } + if ($service instanceof Worker) { + return 'worker'; + } + return 'service'; + } + + /** + * Validates a given instance count. + * + * @param string $value + * @param int|null $limit + * @param string $context + * + * @throws InvalidArgumentException + * + * @return int + */ + protected function validateInstanceCount($value, $limit, $context = '') + { + $count = (int) $value; + if ($count != $value || $value <= 0) { + $message = sprintf('Invalid instance count %s: it must be an integer greater than 0', $value); + if ($context) { + $message .= sprintf(' for %s', $context); + } + throw new InvalidArgumentException($message); + } + if ($limit !== null && $count > $limit) { + $message = sprintf('The instance count %d exceeds the limit %d', $count, $limit); + if ($context) { + $message .= sprintf(' for %s', $context); + } + throw new InvalidArgumentException($message); + } + return $count; + } + + /** + * Formats a duration. + * + * @param int $value + * + * @return string + */ + protected function formatDuration($value) + { + $lookup = array_flip(self::$validDurations); + if (!isset($lookup[$value])) { + throw new InvalidArgumentException(sprintf('Invalid duration %s: must be one of %s', $value, implode(', ', array_keys($lookup)))); + } + return $lookup[$value]; + } + + /** + * Formats a change in a value. + * + * @param int|float|string|null $previousValue + * @param int|float|string|null $newValue + * @param string $suffix A unit suffix e.g. ' MB' + * + * @return string + */ + protected function formatChange($previousValue, $newValue, $suffix = '', callable $comparator = null) + { + if ($previousValue === null || $newValue === $previousValue) { + return sprintf('%s%s', $newValue, $suffix); + } + if ($comparator !== null) { + $changeText = $comparator($previousValue, $newValue) ? 'increasing' : 'decreasing'; + } else if ($newValue === "true" || $newValue === "false") { + $color = $newValue === "true" ? 'green' : 'yellow'; + $changeText = 'changing'; + } else { + $changeText = $newValue > $previousValue ? 'increasing' : 'decreasing'; + } + return sprintf( + '%s from %s%s to %s%s', + $changeText, + $previousValue, $suffix, + $newValue, $suffix + ); + } + + /** + * Formats a change in a duration. + * + * @param int|string $previousValue + * @param int|string $newValue + * + * @return string + */ + protected function formatDurationChange($previousValue, $newValue) + { + return $this->formatChange( + $previousValue, + $newValue, + '', + function ($previousValue, $newValue) { + if (!isset(self::$validDurations[$previousValue]) || !isset(self::$validDurations[$newValue])) { + throw new InvalidArgumentException(sprintf( + 'Invalid duration key(s): previousValue=%s, newValue=%s. Valid keys are: %s', + $previousValue, + $newValue, + implode(', ', array_keys(self::$validDurations)) + )); + } + return self::$validDurations[$previousValue] < self::$validDurations[$newValue]; + } + ); + } +} + diff --git a/src/Command/Resources/ResourcesGetCommand.php b/src/Command/Resources/ResourcesGetCommand.php index 15b1c4728e..7f0facb2a9 100644 --- a/src/Command/Resources/ResourcesGetCommand.php +++ b/src/Command/Resources/ResourcesGetCommand.php @@ -74,11 +74,13 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. - $autoscalingSettings = $this->api()->getAutoscalingSettings($environment)->getData(); $autoscalingEnabled = []; - foreach ($autoscalingSettings['services'] as $service => $serviceSettings) { - $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); + // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. + $autoscalingSettings = $this->api()->getAutoscalingSettings($environment); + if ($autoscalingSettings) { + foreach ($autoscalingSettings->getData()['services'] as $service => $serviceSettings) { + $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); + } } /** @var Table $table */ diff --git a/src/Command/Resources/ResourcesSetCommand.php b/src/Command/Resources/ResourcesSetCommand.php index f64444f464..62044f782e 100644 --- a/src/Command/Resources/ResourcesSetCommand.php +++ b/src/Command/Resources/ResourcesSetCommand.php @@ -50,7 +50,9 @@ protected function configure() '', sprintf('Profile sizes are predefined CPU & memory values that can be viewed by running: %s resources:sizes', $this->config()->get('application.executable')), '', - 'If the same service and resource is specified on the command line multiple times, only the final value will be used.' + 'If the same service and resource is specified on the command line multiple times, only the final value will be used.', + '', + sprintf('You can also configure autoscaling by running %s autoscaling:set', $this->config()->get('application.executable')) ]; if ($this->config()->has('service.resources_help_url')) { $helpLines[] = ''; @@ -97,11 +99,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $instanceLimit = $projectInfo['capabilities']['instance_limit']; } - // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. - $autoscalingSettings = $this->api()->getAutoscalingSettings($environment)->getData(); $autoscalingEnabled = []; - foreach ($autoscalingSettings['services'] as $service => $serviceSettings) { - $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); + // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. + $autoscalingSettings = $this->api()->getAutoscalingSettings($environment); + if ($autoscalingSettings) { + foreach ($autoscalingSettings->getData()['services'] as $service => $serviceSettings) { + $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); + } } // Validate the --size option. diff --git a/src/Service/Api.php b/src/Service/Api.php index fb040ea92c..5de6ee09d8 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -24,6 +24,7 @@ use Platformsh\Client\Connection\Connector; use Platformsh\Client\Exception\ApiResponseException; use Platformsh\Client\Exception\EnvironmentStateException; +use Platformsh\Client\Model\AutoscalingSettings; use Platformsh\Client\Model\BasicProjectInfo; use Platformsh\Client\Model\Deployment\EnvironmentDeployment; use Platformsh\Client\Model\Deployment\Service; @@ -1783,24 +1784,22 @@ public function getCodeSourceIntegration(Project $project) */ public function getAutoscalingSettingsLink(Environment $environment, $manage = false) { - $link = "#autoscaling"; + $rel = "#autoscaling"; if ($manage === true) { - $link = "#manage-autoscaling"; + $rel = "#manage-autoscaling"; } - $environmentData = $environment->getData(); - if (!isset($environmentData['_links'][$link])) { - $this->stdErr->writeln(\sprintf('Autoscaling support is not currently available on the environment: %s', $this->getEnvironmentLabel($environment, 'error'))); - - return false; - } - if (!isset($environmentData['_links'][$link]['href'])) { - $this->stdErr->writeln(\sprintf('Unable to find autoscaling URLs for the environment: %s', $this->getEnvironmentLabel($environment, 'error'))); + if (!$environment->hasLink($rel)) { + $this->debug(\sprintf( + 'The environment %s is missing the link %s', + $environment->id, + $rel + )); return false; } - return $environmentData['_links'][$link]['href']; + return $environment->getLink($rel); } /** @@ -1808,19 +1807,47 @@ public function getAutoscalingSettingsLink(Environment $environment, $manage = f * * @param Environment $environment * - * @return \Platformsh\Client\Model\AutoscalingSettings + * @return \Platformsh\Client\Model\AutoscalingSettings|false + * The autoscaling settings for the environment or false on failure. */ public function getAutoscalingSettings(Environment $environment) { + $autoscalingSettingsLink = $this->getAutoscalingSettingsLink($environment); + if (!$autoscalingSettingsLink) { + return false; + } + + try { + $result = $environment->runOperation('autoscaling', 'get'); + } catch (EnvironmentStateException $e) { + if ($e->getEnvironment()->status === 'inactive') { + throw new EnvironmentStateException('The environment is inactive', $e->getEnvironment()); + } + return false; + } + return new AutoscalingSettings($result->getData(), $autoscalingSettingsLink); + } + + /** + * Configures the autoscaling settings for the selected environment. + * + * @param Environment $environment + * @param array $settings + */ + public function setAutoscalingSettings(Environment $environment, array $settings) + { + if (!$this->getAutoscalingSettingsLink($environment, true)) { + throw new EnvironmentStateException('Managing autoscaling settings is not currently available', $environment); + } + try { - $settings = $environment->getAutoscalingSettings(); + $environment->runOperation('manage-autoscaling', 'patch', $settings); } catch (EnvironmentStateException $e) { if ($e->getEnvironment()->status === 'inactive') { throw new EnvironmentStateException('The environment is inactive', $e->getEnvironment()); } throw $e; } - return $settings; } /** From d0b1eb7b114c22793bc3b7fe723145673fbeb50b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Sep 2025 23:51:47 +0100 Subject: [PATCH 31/37] Fix "This environment is inactive" during activation if the deployment cannot be fetched --- src/Command/Environment/EnvironmentActivateCommand.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Command/Environment/EnvironmentActivateCommand.php b/src/Command/Environment/EnvironmentActivateCommand.php index 54b3d7d8b0..b9d5eb41e9 100644 --- a/src/Command/Environment/EnvironmentActivateCommand.php +++ b/src/Command/Environment/EnvironmentActivateCommand.php @@ -2,6 +2,7 @@ namespace Platformsh\Cli\Command\Environment; use Platformsh\Cli\Command\CommandBase; +use Platformsh\Client\Exception\EnvironmentStateException; use Platformsh\Client\Model\Environment; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -113,7 +114,12 @@ protected function activateMultiple(array $environments, InputInterface $input, continue; } - $hasGuaranteedCPU = $this->api()->environmentHasGuaranteedCPU($environment); + try { + $hasGuaranteedCPU = $this->api()->environmentHasGuaranteedCPU($environment); + } catch (EnvironmentStateException $e) { + $hasGuaranteedCPU = false; + } + $question = "Are you sure you want to activate the environment " . $this->api()->getEnvironmentLabel($environment) . "?"; if ($resourcesInit === 'parent' && $hasGuaranteedCPU && $this->config()->has('warnings.guaranteed_resources_branch_msg')) { $this->stdErr->writeln(''); From a796724d75f9786232b279484761cfdad6e7a2d0 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 19 Sep 2025 13:17:00 +0100 Subject: [PATCH 32/37] Support organization types (#1565) * Add support for organization types to the `orgs` command * Add support for organization types to the `org:create` command * Pass the type on creation * Allow org types to be disabled in the environment * Display the org type in the project:list (projects) command * Re-title project:list columns and display the org label by default, for neater layout * Preserve organization name as the default column * Fix current test expectations * Add unit tests covering org types * Add an --org-type filter to the project list command * Fix golangci-lint in Actions * Update help_test.go --- .github/workflows/ci.yml | 2 +- .github/workflows/golangci-lint.yml | 4 +- composer.json | 2 +- composer.lock | 14 ++-- config-defaults.yaml | 6 ++ config.yaml | 3 + go-tests/config.yaml | 3 + go-tests/go.mod | 14 ++-- go-tests/go.sum | 28 ++++---- go-tests/help_test.go | 4 +- go-tests/org_create_test.go | 24 +++---- go-tests/org_info_test.go | 3 +- go-tests/org_list_test.go | 31 ++++----- go-tests/project_create_test.go | 4 +- go-tests/project_info_test.go | 2 +- go-tests/project_list_test.go | 37 ++++++----- go-tests/user_list_test.go | 2 +- go-tests/web_console_test.go | 2 +- .../OrganizationCreateCommand.php | 64 ++++++++++++------- .../Organization/OrganizationListCommand.php | 13 ++++ src/Command/Project/ProjectListCommand.php | 26 +++++++- src/Service/Config.php | 5 ++ 22 files changed, 184 insertions(+), 109 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 939a7eefb2..a9d668ba82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.24 + go-version: 1.25 cache-dependency-path: cli/go.sum - name: Lint Go files diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index b17fc719fe..7769e5dd5c 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -18,7 +18,7 @@ jobs: go-version: stable - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v8 with: - version: v1.64 + version: v2.4 working-directory: go-tests diff --git a/composer.json b/composer.json index e26230b40e..24118f1fa8 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "guzzlehttp/guzzle": "^5.3", "guzzlehttp/ringphp": "^1.1", "platformsh/console-form": ">=0.0.37 <2.0", - "platformsh/client": ">=0.90.0 <2.0", + "platformsh/client": ">=0.91.0 <2.0", "symfony/console": "^3.0 >=3.2", "symfony/yaml": "^3.0 || ^2.6", "symfony/finder": "^3.0", diff --git a/composer.lock b/composer.lock index 392d451b66..c2daea9735 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c051bb5371e52679c448b7768ceef028", + "content-hash": "031ea1720f8bae2c954d6f041ab8b8a2", "packages": [ { "name": "cocur/slugify", @@ -921,16 +921,16 @@ }, { "name": "platformsh/client", - "version": "0.90.0", + "version": "0.91.0", "source": { "type": "git", "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "1bdce4b84912c563145a45120912186516f0857c" + "reference": "4570352e7243f12440704a145d9a0b2587bc5383" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/1bdce4b84912c563145a45120912186516f0857c", - "reference": "1bdce4b84912c563145a45120912186516f0857c", + "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/4570352e7243f12440704a145d9a0b2587bc5383", + "reference": "4570352e7243f12440704a145d9a0b2587bc5383", "shasum": "" }, "require": { @@ -962,9 +962,9 @@ "description": "Platform.sh API client", "support": { "issues": "https://github.com/platformsh/platformsh-client-php/issues", - "source": "https://github.com/platformsh/platformsh-client-php/tree/0.90.0" + "source": "https://github.com/platformsh/platformsh-client-php/tree/0.91.0" }, - "time": "2025-08-07T16:00:58+00:00" + "time": "2025-09-17T15:35:41+00:00" }, { "name": "platformsh/console-form", diff --git a/config-defaults.yaml b/config-defaults.yaml index 7443449d3c..d570cea594 100644 --- a/config-defaults.yaml +++ b/config-defaults.yaml @@ -248,6 +248,12 @@ api: # Whether the Organizations API is enabled. organizations: false + # A list of supported organization types. + organization_types: [] + + # The default type when creating an organization. + default_organization_type: ~ + # Whether Centralized User Management is available. # Ignored if "organizations" is disabled. centralized_permissions: true diff --git a/config.yaml b/config.yaml index 297195c3b0..fd13cd1146 100644 --- a/config.yaml +++ b/config.yaml @@ -48,6 +48,9 @@ api: auth_url: 'https://auth.api.platform.sh' oauth2_client_id: 'platform-cli' + organization_types: [flexible, fixed] + default_organization_type: flexible + organizations: true user_verification: true metrics: true diff --git a/go-tests/config.yaml b/go-tests/config.yaml index aba6731a08..6ef9db1840 100644 --- a/go-tests/config.yaml +++ b/go-tests/config.yaml @@ -20,6 +20,9 @@ api: disable_credential_helpers: true + organization_types: [flexible, fixed] + default_organization_type: flexible + organizations: true centralized_permissions: true teams: true diff --git a/go-tests/go.mod b/go-tests/go.mod index 9096c60a58..86be1185f8 100644 --- a/go-tests/go.mod +++ b/go-tests/go.mod @@ -1,18 +1,18 @@ module github.com/platformsh/legacy-cli/tests -go 1.24 +go 1.25 require ( - github.com/go-chi/chi/v5 v5.2.2 - github.com/platformsh/cli v0.0.0-20250731203409-d16c54a147ad - github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.38.0 + github.com/go-chi/chi/v5 v5.2.3 + github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.42.0 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/oklog/ulid/v2 v2.1.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go-tests/go.sum b/go-tests/go.sum index 37826cdb5f..62396d180a 100644 --- a/go-tests/go.sum +++ b/go-tests/go.sum @@ -1,22 +1,22 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= -github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= -github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/platformsh/cli v0.0.0-20250731203409-d16c54a147ad h1:8zVPD4Pnyxlfo+CoW6z4xWbKJ24SkFsawcpOA9VxOuo= -github.com/platformsh/cli v0.0.0-20250731203409-d16c54a147ad/go.mod h1:R6GngeR46fJCjZvpoqR+7ccNRLXTHSKzKUQUoDy+6ks= +github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba h1:3WGCXeQ5sGhK+0LBQLb1SGDTBdkqrQUoJJ4yBk8aytY= +github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba/go.mod h1:BIRcjGc1Hikr4axLI3BEjEon4Iuo1AKIkN1L5ILQGmE= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go-tests/help_test.go b/go-tests/help_test.go index 33c5ce02cb..0394dfa390 100644 --- a/go-tests/help_test.go +++ b/go-tests/help_test.go @@ -2,17 +2,17 @@ package tests import ( "encoding/json" - "github.com/stretchr/testify/require" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHelp(t *testing.T) { f := newCommandFactory(t, "", "") assert.Contains(t, f.Run("help", "pro"), - "platform-test projects [--pipe] [--region REGION] [--title TITLE] [--my] [--refresh REFRESH] [--sort SORT] [--reverse] [--page PAGE] [-c|--count COUNT] [-o|--org ORG] [--format FORMAT] [--columns COLUMNS] [--no-header] [--date-fmt DATE-FMT]") + "platform-test projects [--pipe] [--region REGION] [--title TITLE] [--my] [--refresh REFRESH] [--sort SORT] [--reverse] [--page PAGE] [-c|--count COUNT] [-o|--org ORG] [--org-type ORG-TYPE] [--format FORMAT] [--columns COLUMNS] [--no-header] [--date-fmt DATE-FMT]") actListHelp := f.Run("help", "act", "--format", "json") var helpData struct { diff --git a/go-tests/org_create_test.go b/go-tests/org_create_test.go index 38997d14d2..63d69727d7 100644 --- a/go-tests/org_create_test.go +++ b/go-tests/org_create_test.go @@ -18,7 +18,7 @@ func TestOrgCreate(t *testing.T) { apiHandler := mockapi.NewHandler(t) apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("org-id-1", "acme", "ACME Inc.", myUserID), + makeOrg("org-id-1", "acme", "ACME Inc.", myUserID, "flexible"), }) apiServer := httptest.NewServer(apiHandler) @@ -30,11 +30,11 @@ func TestOrgCreate(t *testing.T) { f.Run("cc") assertTrimmed(t, ` -+------+-----------+--------------------------------------+ -| Name | Label | Owner email | -+------+-----------+--------------------------------------+ -| acme | ACME Inc. | user-for-org-create-test@example.com | -+------+-----------+--------------------------------------+ ++------+-----------+----------+--------------------------------------+ +| Name | Label | Type | Owner email | ++------+-----------+----------+--------------------------------------+ +| acme | ACME Inc. | flexible | user-for-org-create-test@example.com | ++------+-----------+----------+--------------------------------------+ `, f.Run("orgs")) _, stdErr, err := f.RunCombinedOutput("org:create", "--name", "hooli", "--yes") @@ -50,11 +50,11 @@ func TestOrgCreate(t *testing.T) { assert.Contains(t, stdErr, "Hooli") assertTrimmed(t, ` -+-------+-----------+--------------------------------------+ -| Name | Label | Owner email | -+-------+-----------+--------------------------------------+ -| acme | ACME Inc. | user-for-org-create-test@example.com | -| hooli | Hooli | user-for-org-create-test@example.com | -+-------+-----------+--------------------------------------+ ++-------+-----------+----------+--------------------------------------+ +| Name | Label | Type | Owner email | ++-------+-----------+----------+--------------------------------------+ +| acme | ACME Inc. | flexible | user-for-org-create-test@example.com | +| hooli | Hooli | flexible | user-for-org-create-test@example.com | ++-------+-----------+----------+--------------------------------------+ `, f.Run("orgs")) } diff --git a/go-tests/org_info_test.go b/go-tests/org_info_test.go index 6fca7ce535..afafbafb4e 100644 --- a/go-tests/org_info_test.go +++ b/go-tests/org_info_test.go @@ -21,13 +21,14 @@ func TestOrgInfo(t *testing.T) { defer apiServer.Close() apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("org-id-1", "org-1", "Org 1", myUserID), + makeOrg("org-id-1", "org-1", "Org 1", myUserID, "flexible"), }) f := newCommandFactory(t, apiServer.URL, authServer.URL) assert.Contains(t, f.Run("org:info", "-o", "org-1", "--format", "csv", "--refresh"), `Property,Value id,org-id-1 +type,flexible name,org-1 label,Org 1 owner_id,user-for-org-info-test diff --git a/go-tests/org_list_test.go b/go-tests/org_list_test.go index fe910d9efd..2853ab8400 100644 --- a/go-tests/org_list_test.go +++ b/go-tests/org_list_test.go @@ -17,9 +17,9 @@ func TestOrgList(t *testing.T) { apiHandler := mockapi.NewHandler(t) apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("org-id-1", "acme", "ACME Inc.", myUserID), - makeOrg("org-id-2", "four-seasons", "Four Seasons Total Landscaping", myUserID), - makeOrg("org-id-3", "duff", "Duff Beer", "user-id-2"), + makeOrg("org-id-1", "acme", "ACME Inc.", myUserID, "flexible"), + makeOrg("org-id-2", "four-seasons", "Four Seasons Total Landscaping", myUserID, "flexible"), + makeOrg("org-id-3", "duff", "Duff Beer", "user-id-2", "fixed"), }) apiServer := httptest.NewServer(apiHandler) @@ -28,20 +28,20 @@ func TestOrgList(t *testing.T) { f := newCommandFactory(t, apiServer.URL, authServer.URL) assertTrimmed(t, ` -+--------------+--------------------------------+-----------------------+ -| Name | Label | Owner email | -+--------------+--------------------------------+-----------------------+ -| acme | ACME Inc. | user-id-1@example.com | -| duff | Duff Beer | user-id-2@example.com | -| four-seasons | Four Seasons Total Landscaping | user-id-1@example.com | -+--------------+--------------------------------+-----------------------+ ++--------------+--------------------------------+----------+-----------------------+ +| Name | Label | Type | Owner email | ++--------------+--------------------------------+----------+-----------------------+ +| acme | ACME Inc. | flexible | user-id-1@example.com | +| duff | Duff Beer | fixed | user-id-2@example.com | +| four-seasons | Four Seasons Total Landscaping | flexible | user-id-1@example.com | ++--------------+--------------------------------+----------+-----------------------+ `, f.Run("orgs")) assertTrimmed(t, ` -Name Label Owner email -acme ACME Inc. user-id-1@example.com -duff Duff Beer user-id-2@example.com -four-seasons Four Seasons Total Landscaping user-id-1@example.com +Name Label Type Owner email +acme ACME Inc. flexible user-id-1@example.com +duff Duff Beer fixed user-id-2@example.com +four-seasons Four Seasons Total Landscaping flexible user-id-1@example.com `, f.Run("orgs", "--format", "plain")) assertTrimmed(t, ` @@ -51,9 +51,10 @@ org-id-2,four-seasons `, f.Run("orgs", "--format", "csv", "--columns", "id,name", "--no-header")) } -func makeOrg(id, name, label, owner string) *mockapi.Org { +func makeOrg(id, name, label, owner, typ string) *mockapi.Org { return &mockapi.Org{ ID: id, + Type: typ, Name: name, Label: label, Owner: owner, diff --git a/go-tests/project_create_test.go b/go-tests/project_create_test.go index 66988bdd17..e773d94721 100644 --- a/go-tests/project_create_test.go +++ b/go-tests/project_create_test.go @@ -18,7 +18,7 @@ func TestProjectCreate(t *testing.T) { apiHandler := mockapi.NewHandler(t) apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("cli-test-id", "cli-tests", "CLI Test Org", "my-user-id"), + makeOrg("cli-test-id", "cli-tests", "CLI Test Org", "my-user-id", "flexible"), }) apiServer := httptest.NewServer(apiHandler) @@ -185,7 +185,7 @@ func TestProjectCreate_CanCreateError(t *testing.T) { orgs := make([]*mockapi.Org, 0, len(cases)) for _, c := range cases { - orgs = append(orgs, makeOrg(c.orgName+"-id", c.orgName, c.orgName, "my-user-id")) + orgs = append(orgs, makeOrg(c.orgName+"-id", c.orgName, c.orgName, "my-user-id", "flexible")) apiHandler.SetCanCreate(c.orgName+"-id", c.canCreateResponse) } apiHandler.SetOrgs(orgs) diff --git a/go-tests/project_info_test.go b/go-tests/project_info_test.go index 9ab3417eb2..85a6023339 100644 --- a/go-tests/project_info_test.go +++ b/go-tests/project_info_test.go @@ -26,7 +26,7 @@ func TestProjectInfo(t *testing.T) { defer apiServer.Close() apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("org-id-1", "org-1", "Org 1", myUserID), + makeOrg("org-id-1", "org-1", "Org 1", myUserID, "flexible"), }) projectID := mockapi.ProjectID() diff --git a/go-tests/project_list_test.go b/go-tests/project_list_test.go index 753397abd2..ffec15f3dd 100644 --- a/go-tests/project_list_test.go +++ b/go-tests/project_list_test.go @@ -22,8 +22,8 @@ func TestProjectList(t *testing.T) { defer apiServer.Close() apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("org-id-1", "org-1", "Org 1", myUserID), - makeOrg("org-id-2", "org-2", "Org 2", otherUserID), + makeOrg("org-id-1", "org-1", "Org 1", myUserID, "flexible"), + makeOrg("org-id-2", "org-2", "Org 2", otherUserID, "fixed"), }) apiHandler.SetProjects([]*mockapi.Project{ makeProject("project-id-1", "org-id-1", vendor, "Project 1", "region-1"), @@ -72,34 +72,39 @@ func TestProjectList(t *testing.T) { f := newCommandFactory(t, apiServer.URL, authServer.URL) assertTrimmed(t, ` -+--------------+-----------+----------+--------------+ -| ID | Title | Region | Organization | -+--------------+-----------+----------+--------------+ -| project-id-1 | Project 1 | region-1 | org-1 | -| project-id-2 | Project 2 | region-2 | org-2 | -| project-id-3 | Project 3 | region-2 | org-2 | -+--------------+-----------+----------+--------------+ ++--------------+-----------+----------+----------+----------+ +| ID | Title | Region | Org name | Org type | ++--------------+-----------+----------+----------+----------+ +| project-id-1 | Project 1 | region-1 | org-1 | flexible | +| project-id-2 | Project 2 | region-2 | org-2 | fixed | +| project-id-3 | Project 3 | region-2 | org-2 | fixed | ++--------------+-----------+----------+----------+----------+ `, f.Run("pro", "-v")) assertTrimmed(t, ` -ID Title Region Organization -project-id-1 Project 1 region-1 org-1 -project-id-2 Project 2 region-2 org-2 -project-id-3 Project 3 region-2 org-2 +ID Title Region Org name Org type +project-id-1 Project 1 region-1 org-1 flexible +project-id-2 Project 2 region-2 org-2 fixed +project-id-3 Project 3 region-2 org-2 fixed `, f.Run("pro", "-v", "--format", "plain")) assertTrimmed(t, ` -ID,Organization ID +ID,Org ID project-id-1,org-id-1 project-id-2,org-id-2 project-id-3,org-id-2 `, f.Run("pro", "-v", "--format", "csv", "--columns", "id,organization_id")) assertTrimmed(t, ` -ID Title Region Organization -project-id-1 Project 1 region-1 org-1 +ID Title Region Org name Org type +project-id-1 Project 1 region-1 org-1 flexible `, f.Run("pro", "-v", "--format", "plain", "--my")) + assertTrimmed(t, ` +ID Title Region Org name Org type +project-id-1 Project 1 region-1 org-1 flexible +`, f.Run("pro", "-v", "--format", "plain", "--org-type", "flexible")) + assertTrimmed(t, ` project-id-1 project-id-2 diff --git a/go-tests/user_list_test.go b/go-tests/user_list_test.go index 7e1b9622cb..73c9e53708 100644 --- a/go-tests/user_list_test.go +++ b/go-tests/user_list_test.go @@ -21,7 +21,7 @@ func TestUserList(t *testing.T) { defer apiServer.Close() apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg("org-id-1", "org-1", "Org 1", myUserID), + makeOrg("org-id-1", "org-1", "Org 1", myUserID, "flexible"), }) apiHandler.SetProjects([]*mockapi.Project{ makeProject(projectID, "org-id-1", vendor, "Project 1", "region-1"), diff --git a/go-tests/web_console_test.go b/go-tests/web_console_test.go index 08e3598492..942b65ea8e 100644 --- a/go-tests/web_console_test.go +++ b/go-tests/web_console_test.go @@ -20,7 +20,7 @@ func TestWebConsole(t *testing.T) { orgID := "org-" + mockapi.NumericID() apiHandler.SetOrgs([]*mockapi.Org{ - makeOrg(orgID, "cli-tests", "CLI Test Org", "my-user-id"), + makeOrg(orgID, "cli-tests", "CLI Test Org", "my-user-id", "flexible"), }) apiHandler.SetProjects([]*mockapi.Project{{ diff --git a/src/Command/Organization/OrganizationCreateCommand.php b/src/Command/Organization/OrganizationCreateCommand.php index 7749d04344..342c794d04 100644 --- a/src/Command/Organization/OrganizationCreateCommand.php +++ b/src/Command/Organization/OrganizationCreateCommand.php @@ -31,29 +31,41 @@ protected function configure() private function getForm() { $countryList = $this->countryList(); - return Form::fromArray([ - 'label' => new Field('Label', [ - 'description' => 'The full name of the organization, e.g. "ACME Inc."', - ]), - 'name' => new Field('Name', [ - 'description' => 'The organization machine name, used for URL paths and similar purposes.', - 'defaultCallback' => function ($values) { - return isset($values['label']) ? (new Slugify())->slugify($values['label']) : null; - }, - ]), - 'country' => new OptionsField('Country', [ - 'description' => 'The organization country. Used as the default for the billing address.', - 'options' => $countryList, - 'asChoice' => false, - 'defaultCallback' => function () { - return $this->api()->getUser()->country ?: null; - }, - 'normalizer' => function ($value) { return $this->normalizeCountryCode($value); }, - 'validator' => function ($countryCode) use ($countryList) { - return isset($countryList[$countryCode]) ? true : "Invalid country: $countryCode"; - }, - ]), + $fields = []; + $fields['label'] = new Field('Label', [ + 'description' => 'The full name of the organization, e.g. "ACME Inc."', ]); + if ($orgTypes = $this->config()->get('api.organization_types')) { + $options = []; + foreach ($orgTypes as $type) { + $options[$type] = ucfirst($type); + } + $fields['type'] = new OptionsField('Type', [ + 'description' => 'The organization type.', + 'options' => $options, + 'default' => $this->config()->getWithDefault('api.default_organization_type', key($options)), + ]); + } + $fields['name'] = new Field('Name', [ + 'description' => 'The organization machine name, used for URL paths and similar purposes.', + 'defaultCallback' => function ($values) { + return isset($values['label']) ? (new Slugify())->slugify($values['label']) : null; + }, + ]); + $fields['country'] = new OptionsField('Country', [ + 'description' => 'The organization country. Used as the default for the billing address.', + 'options' => $countryList, + 'asChoice' => false, + 'defaultCallback' => function () { + return $this->api()->getUser()->country ?: null; + }, + 'normalizer' => function ($value) { return $this->normalizeCountryCode($value); }, + 'validator' => function ($countryCode) use ($countryList) { + return isset($countryList[$countryCode]) ? true : "Invalid country: $countryCode"; + }, + ]); + + return Form::fromArray($fields); } protected function execute(InputInterface $input, OutputInterface $output) @@ -74,7 +86,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } try { - $organization = $client->createOrganization($values['name'], $values['label'], $values['country']); + $organization = $client->createOrganization( + $values['name'], + $values['label'], + $values['country'], + '', + isset($values['type']) ? $values['type'] : '' + ); } catch (BadResponseException $e) { if ($e->getResponse() && $e->getResponse()->getStatusCode() === 409) { $this->stdErr->writeln(\sprintf('An organization already exists with the same name: %s', $values['name'])); diff --git a/src/Command/Organization/OrganizationListCommand.php b/src/Command/Organization/OrganizationListCommand.php index 28f2fbe70f..56ea543712 100644 --- a/src/Command/Organization/OrganizationListCommand.php +++ b/src/Command/Organization/OrganizationListCommand.php @@ -2,6 +2,7 @@ namespace Platformsh\Cli\Command\Organization; use Platformsh\Cli\Service\Table; +use Platformsh\Client\Model\Organization\Organization; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -12,6 +13,7 @@ class OrganizationListCommand extends OrganizationCommandBase 'id' => 'ID', 'name' => 'Name', 'label' => 'Label', + 'type' => 'Type', 'created_at' => 'Created at', 'updated_at' => 'Updated at', 'owner_id' => 'Owner ID', @@ -31,6 +33,12 @@ protected function configure() ->addOption('my', null, InputOption::VALUE_NONE, 'List only the organizations you own') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'An organization property to sort by') ->addOption('reverse', null, InputOption::VALUE_NONE, 'Sort in reverse order'); + + if ($this->config()->get('api.organization_types')) { + $this->addOption('type', null, InputOption::VALUE_REQUIRED, 'Filter organizations by type'); + $this->defaultColumns = ['name', 'label', 'type', 'owner_email']; + } + Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } @@ -48,6 +56,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $organizations = $client->listOrganizationsWithMember($userId); } + if ($input->hasOption('type') && ($type = $input->getOption('type'))) { + $organizations = array_filter($organizations, function (Organization $org) use ($type) { + return $org->getProperty('type', false) === $type; + }); + } if ($sortBy = $input->getOption('sort')) { $this->api()->sortResources($organizations, $sortBy); } diff --git a/src/Command/Project/ProjectListCommand.php b/src/Command/Project/ProjectListCommand.php index b174d0c42d..956af97c07 100644 --- a/src/Command/Project/ProjectListCommand.php +++ b/src/Command/Project/ProjectListCommand.php @@ -20,9 +20,10 @@ class ProjectListCommand extends CommandBase 'id' => 'ID', 'title' => 'Title', 'region' => 'Region', - 'organization_name' => 'Organization', - 'organization_id' => 'Organization ID', - 'organization_label' => 'Organization label', + 'organization_name' => 'Org name', + 'organization_id' => 'Org ID', + 'organization_label' => 'Org label', + 'organization_type' => 'Org type', 'status' => 'Status', 'created_at' => 'Created', ]; @@ -34,6 +35,9 @@ protected function configure() $this->defaultColumns = ['id', 'title', 'region']; if ($organizationsEnabled) { $this->defaultColumns[] = 'organization_name'; + if ($this->config()->get('api.organization_types')) { + $this->defaultColumns[] = 'organization_type'; + } } $this ->setName('project:list') @@ -52,6 +56,9 @@ protected function configure() if ($organizationsEnabled) { $this->addOption('org', 'o', InputOption::VALUE_REQUIRED, 'Filter by organization name or ID'); + if ($this->config()->get('api.organization_types')) { + $this->addOption('org-type', null, InputOption::VALUE_REQUIRED, 'Filter by organization type'); + } } Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); @@ -84,6 +91,9 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($input->hasOption('org') && $input->getOption('org') !== null) { $filters['org'] = $input->getOption('org'); } + if ($input->hasOption('org-type') && $input->getOption('org-type') !== null) { + $filters['org-type'] = $input->getOption('org-type'); + } $this->filterProjects($projects, $filters); // Sort the list of projects. @@ -172,6 +182,7 @@ protected function execute(InputInterface $input, OutputInterface $output) 'organization_id' => $orgInfo ? $orgInfo->id : '', 'organization_name' => $orgInfo ? $orgInfo->name : '', 'organization_label' => $orgInfo ? $orgInfo->label : '', + 'organization_type' => $orgInfo ? (string) $orgInfo->getProperty('type', false) : '', 'status' => $projectInfo->status, 'created_at' => $formatter->format($projectInfo->created_at, 'created_at'), '[deprecated]' => '', @@ -259,6 +270,15 @@ protected function filterProjects(array &$projects, array $filters) return false; }); break; + + case 'org-type': + $projects = \array_filter($projects, function (BasicProjectInfo $info) use ($value) { + if (empty($info->organization_ref)) { + return false; + } + return $info->organization_ref->getProperty('type', false) === $value; + }); + break; } } } diff --git a/src/Service/Config.php b/src/Service/Config.php index 372c8ed917..bb7d6aa11b 100644 --- a/src/Service/Config.php +++ b/src/Service/Config.php @@ -408,6 +408,11 @@ private function applyEnvironmentOverrides() if ($this->getEnv('NO_LEGACY_WARNING')) { $this->config['migrate']['prompt'] = false; } + + // Special case: replace the list api.organization_types with the (split) value of {PREFIX}API_ORGANIZATION_TYPES. + if (($value = $this->getEnv('API_ORGANIZATION_TYPES')) !== false) { + $this->config['api']['organization_types'] = $value === '' ? [] : \preg_split('/[,\s]+/', $value); + } } /** From c50db929b9e88f59435bd77fba811408459237e9 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 19 Sep 2025 13:41:31 +0100 Subject: [PATCH 33/37] Release v4.26.0 --- dist/manifest.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dist/manifest.json b/dist/manifest.json index 2b79af8325..2809beab78 100644 --- a/dist/manifest.json +++ b/dist/manifest.json @@ -1,10 +1,10 @@ [ { - "version": "4.25.0", - "sha1": "b37c28bceb278a76fc5fdd9aadc466cef9e0e4e8", - "sha256": "ef1c284e3793e7906efdc7a21fe1abbe3a23dd982b412cb2a5b821a22669a5c2", + "version": "4.26.0", + "sha1": "d4776f19987f3159f12c99e2bb39fd37bbe59245", + "sha256": "4b215a9abe473cf475a4fe5fc1d9e32234d1ac9bee615a600e9c5ca5d88d03e1", "name": "platform.phar", - "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.25.0/platform.phar", + "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.26.0/platform.phar", "php": { "min": "5.5.9" }, @@ -75,7 +75,8 @@ "4.22.0": "New features:\n\n* Add `--init-repo` option for the `create` (`project:create`) command, as a \n shortcut for creating a project from code in a public repository.\n* Before creating a project, check the organization's permissions. This uses the \n `can-create` API endpoint, which supports more potential action prompts than \n the previous version (which only supported phone verification), e.g. to create \n a support ticket or to update the organization's billing details.\n\nOther changes:\n\n* Make `redis` and `ssh` args consistent; allow multiple args in the `redis` command.\n* Fix error after creating a new organization from a project directory.\n* Fix the project list cache not clearing automatically.", "4.23.0": "New features:\n\n* Add Valkey support\n* Refresh the token and retry requests on a 401 error in :curl commands\n\nOther functional changes:\n\n* Rename \"web\" command to \"console\" (\"web\" remains as an alias)\n* Show \"(legacy)\" in the version output, if not wrapped.\n* Fix the console URL in the project:create (create) command\n* Fix duplication of commands in Markdown-formatted list\n* installer: fix use of GitHub credentials on container where available\n* Update composer/ca-bundle and drush/drush dependencies\n\nDevelopment-related changes:\n\n* Add Golang integration tests in `/go-tests`\n* Run tests and security checks in GitHub Actions\n* Use Composer 2 in dev builds\n* Remove composer-bin-plugin\n* Update countries script for new CLDR format", "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.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." } }, { From ffd1e6597c2acbb51bb9a3d1429cce6ab6c1f54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Haracewiat?= <5583430+mharacewiat@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:36:26 +0200 Subject: [PATCH 34/37] feat: rename otlp integration (#1567) --- src/Command/Integration/IntegrationCommandBase.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Command/Integration/IntegrationCommandBase.php b/src/Command/Integration/IntegrationCommandBase.php index d6390ece7f..532fbd2a42 100644 --- a/src/Command/Integration/IntegrationCommandBase.php +++ b/src/Command/Integration/IntegrationCommandBase.php @@ -185,7 +185,7 @@ private function getFields() 'splunk', 'sumologic', 'syslog', - 'otlp', + 'otlplog', ]; return [ @@ -419,7 +419,7 @@ private function getFields() 'sumologic', 'splunk', 'webhook', - 'otlp', + 'otlplog', ]], 'description' => 'The URL or API endpoint for the integration', ]), @@ -596,7 +596,7 @@ private function getFields() 'splunk', 'sumologic', 'syslog', - 'otlp', + 'otlplog', ]], 'description' => 'Whether HTTPS certificate verification should be enabled (recommended)', 'questionLine' => 'Should HTTPS certificate verification be enabled (recommended)', @@ -608,7 +608,7 @@ private function getFields() 'optionName' => 'header', 'conditions' => ['type' => [ 'httplog', - 'otlp', + 'otlplog', ]], 'description' => 'HTTP header(s) to use in POST requests. Separate names and values with a colon (:).', 'required' => false, From 9c68b6537b1cdeea3ca56e42309b85e7e1c7ead6 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sat, 27 Sep 2025 11:05:32 +0100 Subject: [PATCH 35/37] Set --fail-with-body by default in curl commands --- src/Service/CurlCli.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Service/CurlCli.php b/src/Service/CurlCli.php index 63f1104c25..14f6594ccd 100644 --- a/src/Service/CurlCli.php +++ b/src/Service/CurlCli.php @@ -30,7 +30,7 @@ public static function configureInput(InputDefinition $definition) $definition->addOption(new InputOption('disable-compression', null, InputOption::VALUE_NONE, 'Do not use the curl --compressed flag')); $definition->addOption(new InputOption('enable-glob', null, InputOption::VALUE_NONE, 'Enable curl globbing (remove the --globoff flag)')); $definition->addOption(new InputOption('no-retry-401', null, InputOption::VALUE_NONE, 'Disable automatic retry on 401 errors')); - $definition->addOption(new InputOption('fail', 'f', InputOption::VALUE_NONE, 'Fail with no output on an error response. Default, unless --no-retry-401 is added.')); + $definition->addOption(new InputOption('fail', 'f', InputOption::VALUE_NONE, 'Fail with no output on an error response')); $definition->addOption(new InputOption('header', 'H', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Extra header(s)')); } @@ -57,12 +57,6 @@ public function run($baseUrl, InputInterface $input, OutputInterface $output) { } $retryOn401 = !$input->getOption('no-retry-401'); - if ($retryOn401) { - // Force --fail if retrying on 401 errors. - // This ensures that the error's output will not be printed, which - // is difficult to prevent otherwise. - $input->setOption('fail', true); - } $token = $this->api->getAccessToken(); @@ -153,6 +147,11 @@ private function buildCurlCommand($url, $token, InputInterface $input) } } + // Set --fail-with-body by default. + if (!$input->getOption('fail')) { + $commandline .= ' --fail-with-body'; + } + if ($requestMethod = $input->getOption('request')) { $commandline .= ' --request ' . escapeshellarg($requestMethod); } From 1c9d58a0117d6cfe5646d7851bd2e73821f882bb Mon Sep 17 00:00:00 2001 From: Ricardo Kirkner Date: Mon, 6 Oct 2025 04:41:22 -0300 Subject: [PATCH 36/37] feat: support memory as a trigger for autoscaling (#1564) Co-authored-by: Ricardo Kirkner --- .../Autoscaling/AutoscalingSettingsSetCommand.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php b/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php index 48fc6c5b1d..7db709a4f2 100644 --- a/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php +++ b/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php @@ -198,7 +198,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // Ask for metric name $choices = $supportedMetrics; $default = $choices[0]; - $text = 'Which metric should be used for autoscaling?' . "\n" . 'Default: ' . $default . ''; + $text = 'Which metric should be configured as a trigger for autoscaling?' . "\n" . 'Default: ' . $default . ''; $choice = $questionHelper->choose($choices, $text, 0, false); $metric = $choices[$choice]; } @@ -649,9 +649,9 @@ protected function validateService($value, $services) */ protected function getSupportedMetrics(array $defaults) { - // TODO: change this once we properly support multiple metrics other than 'cpu' - // override supported metrics to only support cpu despite what the backend allows - return ['cpu']; + // TODO: change this once we properly support multiple metrics other than 'cpu' or 'memory' + // override supported metrics to only support cpu/memory despite what the backend allows + return ['cpu', 'memory']; //return array_keys($defaults['triggers']); } @@ -667,7 +667,7 @@ protected function getSupportedMetrics(array $defaults) */ protected function validateMetric($value, $metrics) { - if (array_key_exists($value, $metrics)) { + if (in_array($value, $metrics, true)) { return $value; } throw new InvalidArgumentException(sprintf('Invalid metric name %s. Available metrics: %s', $value, implode(', ', $metrics))); From 82214198080b96e88af5750ca19ed1e0d49cd4f5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 6 Oct 2025 08:45:00 +0100 Subject: [PATCH 37/37] Release v4.27.0 --- dist/manifest.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dist/manifest.json b/dist/manifest.json index 2809beab78..8a9c4f62e8 100644 --- a/dist/manifest.json +++ b/dist/manifest.json @@ -1,10 +1,10 @@ [ { - "version": "4.26.0", - "sha1": "d4776f19987f3159f12c99e2bb39fd37bbe59245", - "sha256": "4b215a9abe473cf475a4fe5fc1d9e32234d1ac9bee615a600e9c5ca5d88d03e1", + "version": "4.27.0", + "sha1": "62e5f46d69cf191324b2baa4a0ee9aa1487d564a", + "sha256": "77b998915dc64a2141809dec08a7e9988045376e4bbcb99005512249813b9c61", "name": "platform.phar", - "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.26.0/platform.phar", + "url": "https://github.com/platformsh/legacy-cli/releases/download/v4.27.0/platform.phar", "php": { "min": "5.5.9" }, @@ -76,7 +76,8 @@ "4.23.0": "New features:\n\n* Add Valkey support\n* Refresh the token and retry requests on a 401 error in :curl commands\n\nOther functional changes:\n\n* Rename \"web\" command to \"console\" (\"web\" remains as an alias)\n* Show \"(legacy)\" in the version output, if not wrapped.\n* Fix the console URL in the project:create (create) command\n* Fix duplication of commands in Markdown-formatted list\n* installer: fix use of GitHub credentials on container where available\n* Update composer/ca-bundle and drush/drush dependencies\n\nDevelopment-related changes:\n\n* Add Golang integration tests in `/go-tests`\n* Run tests and security checks in GitHub Actions\n* Use Composer 2 in dev builds\n* Remove composer-bin-plugin\n* Update countries script for new CLDR format", "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.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`" } }, {