diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3393a13d1..f359db15b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,9 +54,9 @@ jobs:
./scripts/test/unit.sh
- name: Set up Go
- uses: actions/setup-go@v4
+ uses: actions/setup-go@v5
with:
- go-version: 1.22
+ 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 b17fc719f..7769e5dd5 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/README.md b/README.md
index 2ba7e3023..4095704e1 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/composer.lock b/composer.lock
index eaf5b2300..1c6e06b5f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -920,16 +920,16 @@
},
{
"name": "platformsh/client",
- "version": "3.0.0-beta1",
+ "version": "3.0.0-beta2",
"source": {
"type": "git",
"url": "https://github.com/platformsh/platformsh-client-php.git",
- "reference": "6d5e117f952a513a8673d29b83ef2c505cf5c693"
+ "reference": "a4a942450098e20a449867ec1a75e462099560cd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/6d5e117f952a513a8673d29b83ef2c505cf5c693",
- "reference": "6d5e117f952a513a8673d29b83ef2c505cf5c693",
+ "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/a4a942450098e20a449867ec1a75e462099560cd",
+ "reference": "a4a942450098e20a449867ec1a75e462099560cd",
"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/3.0.0-beta1"
+ "source": "https://github.com/platformsh/platformsh-client-php/tree/3.0.0-beta2"
},
- "time": "2024-11-25T20:48:02+00:00"
+ "time": "2025-10-19T15:00:32+00:00"
},
{
"name": "platformsh/console-form",
diff --git a/config-defaults.yaml b/config-defaults.yaml
index c1fef5ab7..4f13925f5 100644
--- a/config-defaults.yaml
+++ b/config-defaults.yaml
@@ -67,6 +67,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: []
@@ -241,6 +244,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
@@ -389,6 +398,12 @@ warnings:
# without the capability.
non_production_domains_msg: null
+ # 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 24bda4436..fd13cd114 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
@@ -37,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'
@@ -47,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
@@ -65,7 +69,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: |
@@ -73,4 +77,13 @@ 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).
+ 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/dist/installer.php b/dist/installer.php
index 13d714681..6b3b87157 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).
@@ -82,7 +82,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/dist/manifest.json b/dist/manifest.json
index 5202b1a74..8a9c4f62e 100644
--- a/dist/manifest.json
+++ b/dist/manifest.json
@@ -1,10 +1,10 @@
[
{
- "version": "4.22.0",
- "sha1": "ec4359c0c6f353190466bab61901ca30fad2f86b",
- "sha256": "911a4b3420533b2eeecd5fa6e54c7c5bcb1baf41c170627f1c29ea3b6c7f7cf5",
+ "version": "4.27.0",
+ "sha1": "62e5f46d69cf191324b2baa4a0ee9aa1487d564a",
+ "sha256": "77b998915dc64a2141809dec08a7e9988045376e4bbcb99005512249813b9c61",
"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.27.0/platform.phar",
"php": {
"min": "5.5.9"
},
@@ -72,7 +72,12 @@
"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",
+ "4.24.0": "New features:\n\n* Support guaranteed resources, adding a new CPU type concept (`shared` or\n `guaranteed`) to the `resources` commands.\n* Support manual deployments:\n - Add an `environment:deploy` command to deploy staged changes.\n - Add an `environment:deploy:type` command to view or change the deployment\n type (between `manual` and `automatic`).\n - Add a `deployment_type` property (read-only) to the `environment:info`\n command.\n - Support the `staged` activity state in activity-related commands.\n\nOther changes:\n\n* Increase the default limits for finding activities.\n* Stop bypassing the organization endpoint for subscriptions.\n* Update Go test dependencies.\n* Add `mark_unwrapped_legacy` config option (defaults to `false`).",
+ "4.25.0": "New features:\n\n* Autoscaling-related features:\n - Add an `autoscaling` command to read autoscaling settings.\n - Mark services with autoscaling in the `resources` command.\n - Disallow changing the instance count in `resources:set` when autoscaling is enabled.\n* Support the OpenTelemetry Protocol (`otlp`) integration, when available on the project.\n* Add a `--strategy` (`-s`) option to the `env:deploy` command (`stopstart` or `rolling`).\n* Require confirmation on `branch` or `env:activate` commands when guaranteed resources are configured.\n\nOther changes:\n\n* Update embedded docs links to the new permalink structure.\n* Avoid writing to stdout when opening a URL.\n* Treat `upsun` and `platformsh` vendors as equivalent in the project list from 2025-09-23.\n* Fix the command recommendation when there are staged activities.\n* Fix the Drush site URL when there is no app route (e.g. with Varnish).",
+ "4.26.0": "New features:\n\n* Add `autoscaling:set` command, to configure CPU-based autoscaling\n* Add support for organization types\n - Display the organization type in the `orgs` list\n - Add a `--type` filter in the `orgs` list\n - Add a `--type` option to `org:create`\n - Display the organization type in the `projects` list\n - Add an `--org-type` filter in the `projects` list\n* Add a `deploy` alias for the `env:deploy` command\n\nOther changes:\n\n* Fix \"This environment is inactive\" during activation, if the deployment cannot \n be fetched.",
+ "4.27.0": "* Support `memory` as a trigger for autoscaling\n* Set `--fail-with-body` by default in `:curl` commands\n* Update name of `otlp` integration to `otlplog`"
}
},
{
diff --git a/go-tests/activity_list_test.go b/go-tests/activity_list_test.go
index bb5d5a0e8..3ae5fb175 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/go-tests/config.yaml b/go-tests/config.yaml
index aba6731a0..6ef9db184 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/environment_deploy_test.go b/go-tests/environment_deploy_test.go
new file mode 100644
index 000000000..252a26729
--- /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/go-tests/environment_deploy_type_test.go b/go-tests/environment_deploy_type_test.go
new file mode 100644
index 000000000..be19b6055
--- /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/environment_info_test.go b/go-tests/environment_info_test.go
index dd2de4fc8..c087e9224 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/go-tests/go.mod b/go-tests/go.mod
index 3671698b4..86be1185f 100644
--- a/go-tests/go.mod
+++ b/go-tests/go.mod
@@ -1,20 +1,18 @@
module github.com/platformsh/legacy-cli/tests
-go 1.23.0
-
-toolchain go1.24.4
+go 1.25
require (
- 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.39.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.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 c3a92c206..62396d180 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/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-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-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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
-golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
-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 33c5ce02c..0394dfa39 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 38997d14d..63d69727d 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 6fca7ce53..afafbafb4 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 fe910d9ef..2853ab840 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 f1258bcee..e773d9472 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)
@@ -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) {
@@ -183,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 9ab3417eb..85a602333 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 753397abd..ffec15f3d 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 7e1b9622c..73c9e5370 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 08e359849..942b65ea8 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/phpstan-baseline.neon b/phpstan-baseline.neon
index d7aca7971..547b8ae3f 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -60,6 +60,11 @@ parameters:
count: 1
path: src/Command/Integration/IntegrationCommandBase.php
+ -
+ message: "#^Method Platformsh\\\\Cli\\\\Command\\\\Integration\\\\IntegrationCommandBase\\:\\:selectedProjectIntegrationCapabilities\\(\\) should return array\\{enabled\\: bool, config\\?\\: array\\\\} but returns array\\.$#"
+ count: 1
+ path: src/Command/Integration/IntegrationCommandBase.php
+
-
message: "#^Cannot call method set\\(\\) on Platformsh\\\\ConsoleForm\\\\Field\\\\Field\\|false\\.$#"
count: 1
diff --git a/src/Application.php b/src/Application.php
index 52501b8e0..075ff5e84 100644
--- a/src/Application.php
+++ b/src/Application.php
@@ -403,7 +403,7 @@ public function setRunningViaMulti(): void
public function getLongVersion(): string
{
// Show "(legacy)" in the version output, if not wrapped.
- if (!$this->config->isWrapped()) {
+ if (!$this->config->isWrapped() && $this->config->getBool('application.mark_unwrapped_legacy')) {
return sprintf('%s (legacy) %s', $this->config->getStr('application.name'), $this->config->getVersion());
}
return sprintf('%s %s', $this->config->getStr('application.name'), $this->config->getVersion());
diff --git a/src/Command/Activity/ActivityCancelCommand.php b/src/Command/Activity/ActivityCancelCommand.php
index a697a731c..fa6309304 100644
--- a/src/Command/Activity/ActivityCancelCommand.php
+++ b/src/Command/Activity/ActivityCancelCommand.php
@@ -77,7 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
->getActivity($id);
if (!$activity) {
/** @var Activity $activity */
- $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel') ?: [], 'Activity');
+ $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, self::DEFAULT_FIND_LIMIT, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel') ?: [], 'Activity');
}
} else {
$activities = $this->activityLoader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel');
diff --git a/src/Command/Activity/ActivityCommandBase.php b/src/Command/Activity/ActivityCommandBase.php
index c25f215c5..637341086 100644
--- a/src/Command/Activity/ActivityCommandBase.php
+++ b/src/Command/Activity/ActivityCommandBase.php
@@ -5,9 +5,30 @@
namespace Platformsh\Cli\Command\Activity;
use Platformsh\Cli\Command\CommandBase;
+use Platformsh\Cli\Service\ActivityLoader;
+use Symfony\Component\Console\Completion\CompletionInput;
+use Symfony\Component\Console\Completion\CompletionSuggestions;
class ActivityCommandBase extends CommandBase
{
protected const STATE_VALUES = ['in_progress', 'pending', 'complete', 'cancelled'];
protected const RESULT_VALUES = ['success', 'failure'];
+
+ protected const DEFAULT_LIST_LIMIT = 10; // Display a digestible number of activities by default.
+ protected const DEFAULT_FIND_LIMIT = 25; // This is the current limit per page of results.
+
+ /**
+ * Runs autocompletion for activity options.
+ */
+ public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
+ {
+ if ($input->mustSuggestOptionValuesFor('type')
+ || $input->mustSuggestOptionValuesFor('exclude-type')) {
+ $suggestions->suggestValues(ActivityLoader::getAvailableTypes());
+ } elseif ($input->mustSuggestOptionValuesFor('state')) {
+ $suggestions->suggestValues(self::STATE_VALUES);
+ } elseif ($input->mustSuggestOptionValuesFor('result')) {
+ $suggestions->suggestValues(self::RESULT_VALUES);
+ }
+ }
}
diff --git a/src/Command/Activity/ActivityGetCommand.php b/src/Command/Activity/ActivityGetCommand.php
index 8d023f425..b280a6ab4 100644
--- a/src/Command/Activity/ActivityGetCommand.php
+++ b/src/Command/Activity/ActivityGetCommand.php
@@ -87,7 +87,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$activity = $selection->getProject()
->getActivity($id);
if (!$activity) {
- $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity');
+ $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, self::DEFAULT_FIND_LIMIT) ?: [], 'Activity');
}
} else {
$activities = $this->activityLoader->loadFromInput($apiResource, $input, 1);
diff --git a/src/Command/Activity/ActivityListCommand.php b/src/Command/Activity/ActivityListCommand.php
index 077f10506..501ee337e 100644
--- a/src/Command/Activity/ActivityListCommand.php
+++ b/src/Command/Activity/ActivityListCommand.php
@@ -78,7 +78,7 @@ protected function configure(): void
ActivityLoader::getAvailableTypes(),
);
- $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, null, self::STATE_VALUES)
->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter activities by result: success or failure', null, self::RESULT_VALUES)
@@ -165,7 +165,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if (!$this->table->formatIsMachineReadable()) {
$executable = $this->config->getStr('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 31b3206d3..d2ff2aadc 100644
--- a/src/Command/Activity/ActivityLogCommand.php
+++ b/src/Command/Activity/ActivityLogCommand.php
@@ -94,7 +94,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
->getActivity($id);
if (!$activity) {
/** @var Activity $activity */
- $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity');
+ $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, self::DEFAULT_FIND_LIMIT) ?: [], 'Activity');
}
} else {
$activities = $this->activityLoader->loadFromInput($apiResource, $input, 1);
diff --git a/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php b/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php
new file mode 100644
index 000000000..dc8e0102d
--- /dev/null
+++ b/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php
@@ -0,0 +1,140 @@
+ */
+ protected array $tableHeader = [
+ 'service' => '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',
+ ];
+
+ /** @var string[] */
+ protected array $defaultColumns = ['service', 'metric', 'direction', 'threshold', 'duration', 'enabled', 'instance_count'];
+
+ public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
+ {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this->selector->addProjectOption($this->getDefinition());
+ $this->selector->addEnvironmentOption($this->getDefinition());
+ Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $selection = $this->selector->getSelection($input);
+ if (!$this->api->supportsAutoscaling($selection->getProject())) {
+ $this->stdErr->writeln(sprintf('The autoscaling API is not enabled for the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment')));
+ return 1;
+ }
+
+ $environment = $selection->getEnvironment();
+
+ 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);
+ 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)) {
+ $this->stdErr->writeln('No apps or workers found.');
+ return 1;
+ }
+
+ if (!empty($autoscalingSettings['services'])) {
+ if (!$this->table->formatIsMachineReadable()) {
+ $this->stdErr->writeln(sprintf('Autoscaling configuration for the project %s, environment %s:', $this->api->getProjectLabel($selection->getProject()), $this->api->getEnvironmentLabel($environment)));
+ }
+
+ $empty = $this->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'] = $this->propertyFormatter->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']) ? $this->propertyFormatter->format($properties['instance_count'], 'instance_count') : '1';
+
+
+ $rows[] = $row;
+ }
+ }
+ }
+
+ $this->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($selection->getProject()), $this->api->getEnvironmentLabel($environment)));
+ $isOriginalCommand = $input instanceof ArgvInput;
+ if ($isOriginalCommand) {
+ $this->stdErr->writeln('');
+ $this->stdErr->writeln(sprintf('You can configure autoscaling by running: %s autoscaling:set', $this->config->getStr('application.executable')));
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php b/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php
new file mode 100644
index 000000000..171162769
--- /dev/null
+++ b/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php
@@ -0,0 +1,882 @@
+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');
+
+ $this->selector->addProjectOption($this->getDefinition());
+ $this->selector->addEnvironmentOption($this->getDefinition());
+
+ $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->getStr('application.executable')),
+ ];
+ if ($this->config->has('service.autoscaling_help_url')) {
+ $helpLines[] = '';
+ $helpLines[] = 'For more information on autoscaling, see: ' . $this->config->getStr('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): int
+ {
+ $selection = $this->selector->getSelection($input);
+ if (!$this->api->supportsAutoscaling($selection->getProject())) {
+ $this->stdErr->writeln(sprintf('The autoscaling API is not enabled for the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment')));
+ return 1;
+ }
+
+ $environment = $selection->getEnvironment();
+
+ 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->subCommandRunner->run('autoscaling:get', [
+ '--project' => $environment->project,
+ '--environment' => $environment->id,
+ ], $this->stdErr)) !== 0) {
+ return $exitCode;
+ }
+
+ $this->stdErr->writeln('');
+
+ // 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 . '';
+ $serviceNamesIndexed = array_combine($serviceNames, $serviceNames);
+ $selectedService = $this->questionHelper->choose($serviceNamesIndexed, $text, $default);
+ $service = $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 configured as a trigger for autoscaling?' . "\n" . 'Default: ' . $default . '';
+ $choicesIndexed = array_combine($choices, $choices);
+ $metric = $this->questionHelper->choose($choicesIndexed, $text, $default, false);
+ }
+
+ $this->handleScalingSettings('up', $service, $metric, $currentServiceSettings, $defaults, $thresholdUp, $durationUp, $cooldownUp, $updates);
+ $this->handleScalingSettings('down', $service, $metric, $currentServiceSettings, $defaults, $thresholdDown, $durationDown, $cooldownDown, $updates);
+ $this->handleInstanceSettings($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->io->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 (!$this->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 array|null $currentServiceSettings
+ * @param array $defaults
+ * @param array> $updates
+ */
+ private function handleScalingSettings(
+ string $direction,
+ string $service,
+ string $metric,
+ ?array $currentServiceSettings,
+ array $defaults,
+ ?float &$threshold,
+ ?int &$duration,
+ ?int &$cooldown,
+ array &$updates,
+ ): void {
+ if ($threshold === null || $duration === null || $cooldown === null) {
+ $text = 'Settings for scaling ' . $direction . '>>';
+ $this->stdErr->writeln($text);
+ $this->stdErr->writeln('');
+
+ $threshold = $this->askForSetting(
+ $threshold,
+ 'Enter the 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(
+ $duration,
+ 'Enter the duration of the evaluation period',
+ $currentServiceSettings['triggers'][$metric][$direction]['duration'] ?? null,
+ $defaults['triggers'][$metric][$direction]['duration'],
+ $service,
+ 'duration-' . $direction,
+ $updates
+ );
+
+ $cooldown = $this->askForDurationSetting(
+ $cooldown,
+ 'Enter the duration of the cool-down period',
+ $currentServiceSettings['scale_cooldown'][$direction] ?? null,
+ $defaults['scale_cooldown'][$direction],
+ $service,
+ 'cooldown-' . $direction,
+ $updates
+ );
+ }
+ }
+
+ /**
+ * Handle instance settings for interactive mode.
+ *
+ * @param array|null $currentServiceSettings
+ * @param array> $updates
+ */
+ private function handleInstanceSettings(
+ string $service,
+ ?array $currentServiceSettings,
+ int $instanceLimit,
+ ?int &$instancesMin,
+ ?int &$instancesMax,
+ array &$updates
+ ): void {
+ $instancesMin = $this->askForSetting(
+ $instancesMin,
+ 'Enter the minimum number of instances',
+ $currentServiceSettings['instances']['min'] ?? null,
+ 1,
+ function ($value) use ($instanceLimit) { return $this->validateInstanceCount($value, $instanceLimit); },
+ $service,
+ 'instances-min',
+ $updates
+ );
+
+ $instancesMax = $this->askForSetting(
+ $instancesMax,
+ 'Enter the maximum number of instances',
+ $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 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(
+ mixed $currentValue,
+ string $prompt,
+ mixed $existingValue,
+ mixed $defaultValue,
+ callable $validator,
+ string $service,
+ string $updateKey,
+ array &$updates
+ ): mixed {
+ if ($currentValue === null) {
+ $default = $existingValue ?? $defaultValue;
+ $newValue = $this->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 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(
+ ?int $currentValue,
+ string $prompt,
+ ?int $existingValue,
+ int $defaultValue,
+ string $service,
+ string $updateKey,
+ array &$updates
+ ): int {
+ if ($currentValue === null) {
+ $choices = array_keys(self::$validDurations);
+ $default = $existingValue ?? $defaultValue;
+ $newValue = $this->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(array $updates): array
+ {
+ $data = ['services' => []];
+
+ foreach ($updates as $service => $serviceSettings) {
+ $serviceData = [];
+ if (isset($serviceSettings['metric'])) {
+ $triggerData = [];
+ if (isset($serviceSettings['threshold-up'])) {
+ $triggerData['up'] = ['threshold' => $serviceSettings['threshold-up']];
+ }
+ if (isset($serviceSettings['duration-up'])) {
+ if (isset($triggerData['up'])) {
+ $triggerData['up']['duration'] = $serviceSettings['duration-up'];
+ } else {
+ $triggerData['up'] = ['duration' => $serviceSettings['duration-up']];
+ }
+ }
+ if (isset($serviceSettings['threshold-down'])) {
+ $triggerData['down'] = ['threshold' => $serviceSettings['threshold-down']];
+ }
+ if (isset($serviceSettings['duration-down'])) {
+ if (isset($triggerData['down'])) {
+ $triggerData['down']['duration'] = $serviceSettings['duration-down'];
+ } else {
+ $triggerData['down'] = ['duration' => $serviceSettings['duration-down']];
+ }
+ }
+ if (isset($serviceSettings['enabled'])) {
+ $triggerData['enabled'] = $serviceSettings['enabled'];
+ }
+ $serviceData['triggers'] = [$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
+ */
+ private function summarizeChanges(array $updates, array $settings): void
+ {
+ $this->stdErr->writeln('Summary of changes:>');
+ foreach ($updates as $service => $serviceUpdates) {
+ $this->summarizeChangesPerService($service, $settings[$service] ?? null, $serviceUpdates);
+ }
+ }
+
+ /**
+ * Summarizes changes per service.
+ *
+ * @param array|null $current
+ * @param array $updates
+ */
+ private function summarizeChangesPerService(string $name, ?array $current, array $updates): void
+ {
+ $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->resourcesUtil->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->resourcesUtil->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->resourcesUtil->formatChange(
+ isset($current['instances']) ? $current['instances']['min'] : null,
+ $updates['instances-min']
+ ));
+ }
+
+ if (isset($updates['instances-max'])) {
+ $this->stdErr->writeln(' Max: ' . $this->resourcesUtil->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(string $value, array $services): string
+ {
+ 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): array
+ {
+ // 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']);
+ }
+
+ /**
+ * 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(string $value, array $metrics): string
+ {
+ if (in_array($value, $metrics, true)) {
+ 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(float|int|string|bool $value): bool
+ {
+ if (is_bool($value)) {
+ return $value;
+ }
+
+ return match ($value) {
+ "true", "yes" => true,
+ "false", "no" => 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(float|int $value, string $context = ''): float
+ {
+ $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;
+ }
+
+ /**
+ * @var array
+ */
+ private static array $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(string $value, string $context = ''): int
+ {
+ 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(WebApp|Worker|Service $service): string
+ {
+ if ($service instanceof WebApp) {
+ return 'app';
+ }
+ if ($service instanceof Worker) {
+ return 'worker';
+ }
+ return 'service';
+ }
+
+ /**
+ * Validates a given instance count.
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function validateInstanceCount(string $value, ?int $limit, string $context = ''): int
+ {
+ $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.
+ */
+ protected function formatDuration(int $value): string
+ {
+ $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 duration.
+ *
+ * @param int|string $previousValue
+ * @param int|string $newValue
+ *
+ * @return string
+ */
+ protected function formatDurationChange(int|string $previousValue, int|string $newValue): string
+ {
+ return $this->resourcesUtil->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/Domain/DomainCommandBase.php b/src/Command/Domain/DomainCommandBase.php
index 901c0590e..58fcaacd2 100644
--- a/src/Command/Domain/DomainCommandBase.php
+++ b/src/Command/Domain/DomainCommandBase.php
@@ -231,11 +231,7 @@ protected function handleApiException(ClientException $e, Project $project): voi
*/
protected function supportsNonProductionDomains(Project $project): bool
{
- 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/Environment/EnvironmentActivateCommand.php b/src/Command/Environment/EnvironmentActivateCommand.php
index 6aab72954..6771d6a0c 100644
--- a/src/Command/Environment/EnvironmentActivateCommand.php
+++ b/src/Command/Environment/EnvironmentActivateCommand.php
@@ -13,6 +13,7 @@
use Platformsh\Cli\Service\Config;
use Platformsh\Cli\Service\QuestionHelper;
use Platformsh\Cli\Command\CommandBase;
+use Platformsh\Client\Exception\EnvironmentStateException;
use Platformsh\Client\Model\Environment;
use Platformsh\Client\Model\Project;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -113,7 +114,7 @@ protected function activateMultiple(array $environments, Project $project, Input
]) === 0;
}
$output->writeln(sprintf(
- 'To resume the environment, run: %s environment:resume',
+ 'To resume the environment, run: %s env:resume',
$this->config->getStr('application.executable'),
));
$count--;
@@ -132,7 +133,20 @@ protected function activateMultiple(array $environments, Project $project, Input
}
continue;
}
+
+ try {
+ $hasGuaranteedCPU = $this->api->environmentHasGuaranteedCPU($environment, $project);
+ } catch (EnvironmentStateException) {
+ $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('');
+ $question = trim($this->config->getStr('warnings.guaranteed_resources_branch_msg'))
+ . "\n\n" . $question;
+ }
+
if (!$this->questionHelper->confirm($question)) {
continue;
}
diff --git a/src/Command/Environment/EnvironmentBranchCommand.php b/src/Command/Environment/EnvironmentBranchCommand.php
index f8f585c88..31dc4677c 100644
--- a/src/Command/Environment/EnvironmentBranchCommand.php
+++ b/src/Command/Environment/EnvironmentBranchCommand.php
@@ -173,6 +173,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->stdErr->writeln('Resource sizes will be inherited from the parent environment.');
}
+ $hasGuaranteedCPU = $this->api->environmentHasGuaranteedCPU($parentEnvironment, $selectedProject);
+ if ($resourcesInit === 'parent' && $hasGuaranteedCPU && $this->config->has('warnings.guaranteed_resources_branch_msg')) {
+ $this->stdErr->writeln('');
+ $questionText = trim($this->config->getStr('warnings.guaranteed_resources_branch_msg'))
+ . "\n\n" . "Are you sure you want to continue?";
+
+ if (!$this->questionHelper->confirm($questionText)) {
+ return 1;
+ }
+ }
+
if ($dryRun) {
$this->stdErr->writeln('');
if ($checkoutLocally) {
@@ -195,6 +206,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if ($resourcesInit !== null) {
$params['resources']['init'] = $resourcesInit;
}
+
$result = $parentEnvironment->runOperation('branch', 'POST', $params);
$activities = $result->getActivities();
diff --git a/src/Command/Environment/EnvironmentCheckoutCommand.php b/src/Command/Environment/EnvironmentCheckoutCommand.php
index 3000ba140..7186ffe0d 100644
--- a/src/Command/Environment/EnvironmentCheckoutCommand.php
+++ b/src/Command/Environment/EnvironmentCheckoutCommand.php
@@ -83,8 +83,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
// Make sure that remotes are set up correctly.
- $localProject = $this->localProject;
- $localProject->ensureGitRemote($projectRoot, $project->getGitUrl());
+ $this->localProject->ensureGitRemote($projectRoot, $project->getGitUrl());
// Determine the correct upstream for the new branch. If there is an
// 'origin' remote, then it has priority.
diff --git a/src/Command/Environment/EnvironmentDeployCommand.php b/src/Command/Environment/EnvironmentDeployCommand.php
new file mode 100644
index 000000000..857f61af6
--- /dev/null
+++ b/src/Command/Environment/EnvironmentDeployCommand.php
@@ -0,0 +1,140 @@
+ */
+ private array $tableHeader = [
+ 'id' => 'ID',
+ 'created' => 'Created',
+ 'description' => 'Description',
+ 'type' => 'Type',
+ 'result' => 'Result',
+ ];
+
+ public function __construct(private readonly ActivityMonitor $activityMonitor, private readonly Api $api, private readonly PropertyFormatter $propertyFormatter, private readonly QuestionHelper $questionHelper, private readonly Selector $selector, private readonly Table $table)
+ {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addOption(
+ 'strategy',
+ 's',
+ InputOption::VALUE_REQUIRED,
+ 'The deployment strategy, stopstart (default, restart with a shutdown) or rolling (zero downtime)'
+ );
+ $this->selector->addProjectOption($this->getDefinition());
+ $this->selector->addEnvironmentOption($this->getDefinition());
+ $this->activityMonitor->addWaitOptions($this->getDefinition());
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $selection = $this->selector->getSelection(
+ $input,
+ new SelectorConfig(
+ chooseEnvFilter: SelectorConfig::filterEnvsByStatus(['active', 'paused'])
+ ),
+ );
+
+ $environment = $selection->getEnvironment();
+
+ 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;
+ }
+
+ $rows = [];
+ foreach ($activities as $activity) {
+ $row = [
+ 'id' => new AdaptiveTableCell($activity->id, ['wrap' => false]),
+ 'created' => $this->propertyFormatter->format($activity['created_at'], 'created_at'),
+ 'description' => ActivityMonitor::getFormattedDescription($activity, !$this->table->formatIsMachineReadable()),
+ 'type' => new AdaptiveTableCell($activity->type, ['wrap' => false]),
+ 'result' => ActivityMonitor::formatResult($activity->result, !$this->table->formatIsMachineReadable()),
+ ];
+ $rows[] = $row;
+ }
+
+ $this->stdErr->writeln(sprintf(
+ 'The following changes will be deployed to the environment %s:',
+ $this->api->getEnvironmentLabel($environment, 'comment')
+ ));
+ $this->table->render($rows, $this->tableHeader);
+
+ $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 = $this->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 (!$this->questionHelper->confirm('Are you sure you want to continue?')) {
+ return 1;
+ }
+
+ $result = $environment->runOperation('deploy', 'POST', ['strategy' => $strategy]);
+
+ if ($this->activityMonitor->shouldWait($input)) {
+ $success = $this->activityMonitor->waitMultiple($result->getActivities(), $selection->getProject());
+ if (!$success) {
+ return 1;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Command/Environment/EnvironmentDeployTypeCommand.php b/src/Command/Environment/EnvironmentDeployTypeCommand.php
new file mode 100644
index 000000000..74d487a53
--- /dev/null
+++ b/src/Command/Environment/EnvironmentDeployTypeCommand.php
@@ -0,0 +1,105 @@
+addArgument('type', InputArgument::OPTIONAL, 'The environment deployment type: automatic or manual.')
+ ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the deployment type to stdout');
+ $this->selector->addProjectOption($this->getDefinition());
+ $this->selector->addEnvironmentOption($this->getDefinition());
+ $this->activityMonitor->addWaitOptions($this->getDefinition());
+ $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): int
+ {
+ $selection = $this->selector->getSelection($input, new SelectorConfig(
+ chooseEnvFilter: SelectorConfig::filterEnvsByStatus(['active', 'paused']),
+ ));
+
+ $environment = $selection->getEnvironment();
+ $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->selector->ensurePrintedSelection($selection, 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->selector->ensurePrintedSelection($selection, 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.');
+ if (!$this->questionHelper->confirm('Are you sure you want to continue?')) {
+ return 1;
+ }
+ }
+ }
+
+ $result = $settings->update(['enable_manual_deployments' => $newType === 'manual']);
+
+ if ($result->getActivities() && $this->activityMonitor->shouldWait($input)) {
+ $success = $this->activityMonitor->waitMultiple($result->getActivities(), $selection->getProject());
+ if (!$success) {
+ return 1;
+ }
+ }
+
+ $this->stdErr->writeln(sprintf('The deployment type was updated successfully to: %s', $newType));
+
+ return 0;
+ }
+}
diff --git a/src/Command/Environment/EnvironmentInfoCommand.php b/src/Command/Environment/EnvironmentInfoCommand.php
index bfa30b8cf..8bf6a57a4 100644
--- a/src/Command/Environment/EnvironmentInfoCommand.php
+++ b/src/Command/Environment/EnvironmentInfoCommand.php
@@ -4,6 +4,7 @@
namespace Platformsh\Cli\Command\Environment;
+use Platformsh\Cli\Service\Config;
use Platformsh\Cli\Selector\Selector;
use Platformsh\Cli\Service\ActivityMonitor;
use Platformsh\Cli\Service\Api;
@@ -23,7 +24,7 @@
#[AsCommand(name: 'environment:info', description: 'Read or set properties for an environment')]
class EnvironmentInfoCommand extends CommandBase
{
- public function __construct(private readonly ActivityMonitor $activityMonitor, private readonly Api $api, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
+ public function __construct(private readonly ActivityMonitor $activityMonitor, private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table)
{
parent::__construct();
}
@@ -93,6 +94,10 @@ protected function listProperties(Environment $environment): int
$headings[] = new AdaptiveTableCell($key, ['wrap' => false]);
$values[] = $this->propertyFormatter->format($value, $key);
}
+
+ $headings[] = 'deployment_type';
+ $values[] = $environment->getSettings()->enable_manual_deployments ? 'manual' : 'automatic';
+
$this->table->renderSimple($values, $headings);
return 0;
@@ -183,6 +188,14 @@ protected function getType(string $property): string|false
protected function validateValue(string $property, string $value, Environment $environment, Project $project): bool
{
+ if ($property == 'deployment_type') {
+ $this->stdErr->writeln(
+ 'Set the deployment type with: ' . $this->config->getStr('application.executable')
+ . ' environment:deploy:type',
+ );
+ return false;
+ }
+
$type = $this->getType($property);
if (!$type) {
$this->stdErr->writeln("Property not writable: $property");
diff --git a/src/Command/Environment/EnvironmentSshCommand.php b/src/Command/Environment/EnvironmentSshCommand.php
index 1298eef07..230eeb2dc 100644
--- a/src/Command/Environment/EnvironmentSshCommand.php
+++ b/src/Command/Environment/EnvironmentSshCommand.php
@@ -79,7 +79,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$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->getStr('application.executable'), OsUtil::escapeShellArg($environment->id)));
+ $this->stdErr->writeln(sprintf('Resume the environment by running: %s env:resume -e %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($environment->id)));
}
return 1;
}
diff --git a/src/Command/Integration/Activity/IntegrationActivityListCommand.php b/src/Command/Integration/Activity/IntegrationActivityListCommand.php
index c7c5116dc..8fc25ed8f 100644
--- a/src/Command/Integration/Activity/IntegrationActivityListCommand.php
+++ b/src/Command/Integration/Activity/IntegrationActivityListCommand.php
@@ -67,7 +67,7 @@ protected function configure(): void
. "\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', 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.' . "\n" . ArrayArgument::SPLIT_HELP)
->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter activities by result')
@@ -132,7 +132,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if (!$this->table->formatIsMachineReadable()) {
$executable = $this->config->getStr('application.executable');
- $max = $input->getOption('limit') ? (int) $input->getOption('limit') : 10;
+ $max = $input->getOption('limit') ? (int) $input->getOption('limit') : self::DEFAULT_LIST_LIMIT;
$maybeMoreAvailable = count($activities) === $max;
if ($maybeMoreAvailable) {
$this->stdErr->writeln('');
diff --git a/src/Command/Integration/IntegrationCommandBase.php b/src/Command/Integration/IntegrationCommandBase.php
index 92f137ad4..f29e58785 100644
--- a/src/Command/Integration/IntegrationCommandBase.php
+++ b/src/Command/Integration/IntegrationCommandBase.php
@@ -30,6 +30,9 @@
abstract class IntegrationCommandBase extends CommandBase
{
+ protected const DEFAULT_LIST_LIMIT = 10; // Display a digestible number of activities by default.
+ protected const DEFAULT_FIND_LIMIT = 25; // This is the current limit per page of results.
+
private Selector $selector;
private Table $table;
private QuestionHelper $questionHelper;
@@ -176,14 +179,11 @@ protected function postProcessValues(array $values, ?Integration $integration =
*
* @return array{enabled: bool, config?: array}
*/
- private function selectedProjectIntegrations(): array
+ private function selectedProjectIntegrationCapabilities(): array
{
- static $cache = [];
- $project = $this->selection->getProject();
- if (!isset($cache[$project->id])) {
- $cache[$project->id] = $project->hasLink('#capabilities') ? $project->getCapabilities()->integrations : [];
- }
- return $cache[$project->id];
+ return $this->api
+ ->getProjectCapabilities($this->selection->getProject())
+ ->integrations;
}
/**
@@ -207,6 +207,7 @@ private function getFields(): array
'splunk',
'sumologic',
'syslog',
+ 'otlplog',
];
return [
@@ -222,7 +223,7 @@ private function getFields(): array
}
// If the type is supported, check if it is available on the project.
if ($this->selection->hasProject()) {
- $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.";
}
@@ -231,7 +232,7 @@ private function getFields(): array
},
'optionsCallback' => function () use ($allSupportedTypes): array {
if ($this->selection->hasProject()) {
- $integrations = $this->selectedProjectIntegrations();
+ $integrations = $this->selectedProjectIntegrationCapabilities();
if (!empty($integrations['enabled']) && !empty($integrations['config'])) {
return array_filter($allSupportedTypes, fn($type): bool => !empty($integrations['config'][$type]['enabled']));
}
@@ -434,6 +435,7 @@ private function getFields(): array
'sumologic',
'splunk',
'webhook',
+ 'otlplog',
]],
'description' => 'The URL or API endpoint for the integration',
]),
@@ -610,6 +612,7 @@ private function getFields(): array
'splunk',
'sumologic',
'syslog',
+ 'otlplog',
]],
'description' => 'Whether HTTPS certificate verification should be enabled (recommended)',
'questionLine' => 'Should HTTPS certificate verification be enabled (recommended)',
@@ -619,7 +622,10 @@ private function getFields(): array
]),
'headers' => new ArrayField('HTTP header', [
'optionName' => 'header',
- 'conditions' => ['type' => 'httplog'],
+ 'conditions' => ['type' => [
+ 'httplog',
+ 'otlplog',
+ ]],
'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
diff --git a/src/Command/Organization/OrganizationCreateCommand.php b/src/Command/Organization/OrganizationCreateCommand.php
index 9bb880c75..be019793e 100644
--- a/src/Command/Organization/OrganizationCreateCommand.php
+++ b/src/Command/Organization/OrganizationCreateCommand.php
@@ -42,23 +42,41 @@ protected function configure(): void
private function getForm(): Form
{
$countryList = $this->countryService->listCountries();
- 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' => fn($values) => 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' => fn() => $this->api->getUser()->country ?: null,
- 'normalizer' => $this->countryService->countryToCode(...),
- 'validator' => fn($countryCode) => 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 ((array) $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->countryService->countryToCode($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): int
@@ -76,7 +94,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
try {
- $organization = $client->createOrganization($values['name'], $values['label'], $values['country']);
+ $organization = $client->createOrganization(
+ $values['name'],
+ $values['label'],
+ $values['country'],
+ '',
+ $values['type'] ?? ''
+ );
} catch (BadResponseException $e) {
if ($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 92f6b4f49..0388019de 100644
--- a/src/Command/Organization/OrganizationListCommand.php
+++ b/src/Command/Organization/OrganizationListCommand.php
@@ -9,6 +9,7 @@
use Platformsh\Cli\Service\Config;
use Platformsh\Cli\Service\Table;
use Symfony\Component\Console\Attribute\AsCommand;
+use Platformsh\Client\Model\Organization\Organization;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -21,6 +22,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',
@@ -40,6 +42,12 @@ protected function configure(): void
->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);
}
@@ -57,6 +65,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$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 954b318db..d8affeeed 100644
--- a/src/Command/Project/ProjectListCommand.php
+++ b/src/Command/Project/ProjectListCommand.php
@@ -29,9 +29,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',
];
@@ -54,6 +55,9 @@ protected function configure(): void
$this->defaultColumns = ['id', 'title', 'region'];
if ($organizationsEnabled) {
$this->defaultColumns[] = 'organization_name';
+ if ($this->config->get('api.organization_types')) {
+ $this->defaultColumns[] = 'organization_type';
+ }
}
$this
->addOption('pipe', null, InputOption::VALUE_NONE, 'Output a simple list of project IDs. Disables pagination.')
@@ -69,6 +73,9 @@ protected function configure(): void
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);
@@ -101,6 +108,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
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.
@@ -183,6 +193,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
'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' => $this->propertyFormatter->format($projectInfo->created_at, 'created_at'),
'[deprecated]' => '',
@@ -266,6 +277,15 @@ protected function filterProjects(array &$projects, array $filters): void
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/Command/Resources/Build/BuildResourcesSetCommand.php b/src/Command/Resources/Build/BuildResourcesSetCommand.php
index fec39caf5..7a21c4ded 100644
--- a/src/Command/Resources/Build/BuildResourcesSetCommand.php
+++ b/src/Command/Resources/Build/BuildResourcesSetCommand.php
@@ -42,7 +42,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
$project = $selection->getProject();
- $capabilities = $project->getCapabilities();
+ $capabilities = $this->api->getProjectCapabilities($project);
$capability = $capabilities->getProperty('build_resources', false);
$maxCpu = $capability ? $capability['max_cpu'] : null;
diff --git a/src/Command/Resources/ResourcesCommandBase.php b/src/Command/Resources/ResourcesCommandBase.php
index c4ae8862d..2317b058a 100644
--- a/src/Command/Resources/ResourcesCommandBase.php
+++ b/src/Command/Resources/ResourcesCommandBase.php
@@ -4,9 +4,15 @@
namespace Platformsh\Cli\Command\Resources;
+use Platformsh\Cli\Command\CommandBase;
+use Platformsh\Cli\Console\ArrayArgument;
use Platformsh\Cli\Service\Config;
+use Platformsh\Cli\Util\Wildcard;
+use Platformsh\Client\Model\Deployment\Service;
+use Platformsh\Client\Model\Deployment\WebApp;
+use Platformsh\Client\Model\Deployment\Worker;
+use Symfony\Component\Console\Input\InputInterface;
use Symfony\Contracts\Service\Attribute\Required;
-use Platformsh\Cli\Command\CommandBase;
class ResourcesCommandBase extends CommandBase
{
@@ -22,4 +28,181 @@ public function isHidden(): bool
{
return !$this->config->getBool('api.sizing') || parent::isHidden();
}
+
+ /**
+ * Checks whether a service needs a persistent disk.
+ *
+ * @param WebApp|Service|Worker $service
+ * @return bool
+ */
+ protected function supportsDisk($service)
+ {
+ // Workers use the disk of their parent app.
+ if ($service instanceof Worker) {
+ return false;
+ }
+ return isset($service->getProperties()['resources']['minimum']['disk']);
+ }
+
+ /**
+ * Filters a list of services according to the --service or --type options.
+ *
+ * @param array $services
+ * @param InputInterface $input
+ *
+ * @return WebApp[]|Service[]|Worker[]|false
+ * False on error, or an array of services.
+ */
+ protected function filterServices($services, InputInterface $input)
+ {
+ $selectedNames = [];
+
+ $requestedServices = ArrayArgument::getOption($input, 'service');
+ if (!empty($requestedServices)) {
+ $selectedNames = Wildcard::select(array_keys($services), $requestedServices);
+ if (!$selectedNames) {
+ $this->stdErr->writeln('No services were found matching the name(s): ' . implode(', ', $requestedServices) . '');
+ return false;
+ }
+ $services = array_intersect_key($services, array_flip($selectedNames));
+ }
+ $requestedApps = ArrayArgument::getOption($input, 'app');
+ if (!empty($requestedApps)) {
+ $selectedNames = Wildcard::select(array_keys(array_filter($services, function ($s) { return $s instanceof WebApp; })), $requestedApps);
+ if (!$selectedNames) {
+ $this->stdErr->writeln('No applications were found matching the name(s): ' . implode(', ', $requestedApps) . '');
+ return false;
+ }
+ $services = array_intersect_key($services, array_flip($selectedNames));
+ }
+ $requestedWorkers = ArrayArgument::getOption($input, 'worker');
+ if (!empty($requestedWorkers)) {
+ $selectedNames = Wildcard::select(array_keys(array_filter($services, function ($s) { return $s instanceof Worker; })), $requestedWorkers);
+ if (!$selectedNames) {
+ $this->stdErr->writeln('No workers were found matching the name(s): ' . implode(', ', $requestedWorkers) . '');
+ return false;
+ }
+ $services = array_intersect_key($services, array_flip($selectedNames));
+ }
+
+ if ($input->hasOption('type') && ($requestedTypes = ArrayArgument::getOption($input, 'type'))) {
+ $byType = [];
+ foreach ($services as $name => $service) {
+ $type = $service->type;
+ [$prefix] = explode(':', $service->type, 2);
+ $byType[$type][] = $name;
+ $byType[$prefix][] = $name;
+ }
+ $selectedTypes = Wildcard::select(array_keys($byType), $requestedTypes);
+ if (!$selectedTypes) {
+ $this->stdErr->writeln('No services were found matching the type(s): ' . implode(', ', $requestedTypes) . '');
+ return false;
+ }
+ foreach ($selectedTypes as $selectedType) {
+ $selectedNames = array_merge($selectedNames, $byType[$selectedType]);
+ }
+ $services = array_intersect_key($services, array_flip($selectedNames));
+ }
+
+ return $services;
+ }
+
+ /**
+ * Returns container profile size info, given service properties.
+ *
+ * @param array $properties
+ * The service properties (e.g. from $service->getProperties()).
+ * @param array>> $containerProfiles
+ * The list of container profiles (e.g. from
+ * $deployment->container_profiles).
+ *
+ * @return array|null
+ */
+ protected function sizeInfo(array $properties, array $containerProfiles)
+ {
+ if (isset($properties['resources']['profile_size'])) {
+ $size = $properties['resources']['profile_size'];
+ $profile = $properties['container_profile'];
+ if (isset($containerProfiles[$profile][$size])) {
+ return $containerProfiles[$profile][$size];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 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 = '')
+ {
+ if ($previousValue === null || $newValue === $previousValue) {
+ return sprintf('%s%s', $newValue, $suffix);
+ }
+ return sprintf(
+ '%s from %s%s to %s%s',
+ $newValue > $previousValue ? 'increasing>' : 'decreasing>',
+ $previousValue,
+ $suffix,
+ $newValue,
+ $suffix
+ );
+ }
+
+ /**
+ * Formats a CPU amount.
+ *
+ * @param int|float|string $unformatted
+ *
+ * @return string
+ * A numeric (still comparable) string with 1 decimal place.
+ */
+ protected function formatCPU($unformatted)
+ {
+ return sprintf('%.1f', $unformatted);
+ }
+
+ /**
+ * Format CPU Type.
+ *
+ * @param array|null $sizeInfo
+ *
+ * @return string
+ */
+ protected function formatCPUType($sizeInfo)
+ {
+ $size = $sizeInfo ? $sizeInfo['cpu'] : null;
+ if ($size === null) {
+ return "";
+ }
+
+ if (!isset($sizeInfo['cpu_type'])) {
+ 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 8364a1161..74683613b 100644
--- a/src/Command/Resources/ResourcesGetCommand.php
+++ b/src/Command/Resources/ResourcesGetCommand.php
@@ -26,6 +26,7 @@ class ResourcesGetCommand extends ResourcesCommandBase
'type' => 'Type',
'profile' => 'Profile',
'profile_size' => 'Size',
+ 'cpu_type' => 'CPU type',
'cpu' => 'CPU',
'memory' => 'Memory (MB)',
'disk' => 'Disk (MB)',
@@ -33,8 +34,10 @@ class ResourcesGetCommand extends ResourcesCommandBase
'base_memory' => 'Base memory',
'memory_ratio' => 'Memory ratio',
];
+
/** @var string[] */
- protected array $defaultColumns = ['service', 'profile_size', 'cpu', 'memory', 'disk', 'instance_count'];
+ protected array $defaultColumns = ['service', 'profile_size', 'cpu_type', 'cpu', 'memory', 'disk', 'instance_count'];
+
public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly ResourcesUtil $resourcesUtil, private readonly Selector $selector, private readonly Table $table)
{
parent::__construct();
@@ -46,7 +49,8 @@ protected function configure(): void
->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->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->addCompleter($this->selector);
@@ -87,6 +91,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 1;
}
+ $autoscalingEnabled = [];
+ // 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']);
+ }
+ }
+
if (!$this->table->formatIsMachineReadable()) {
$this->stdErr->writeln(sprintf('Resource configuration for the project %s, environment %s:', $this->api->getProjectLabel($selection->getProject()), $this->api->getEnvironmentLabel($environment)));
}
@@ -94,11 +107,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$empty = $this->table->formatIsMachineReadable() ? '' : 'not set';
$notApplicable = $this->table->formatIsMachineReadable() ? '' : 'N/A';
- $containerProfiles = $nextDeployment->container_profiles;
+ $containerProfiles = $this->sortContainerProfiles($nextDeployment->container_profiles);
$rows = [];
+ $cpuTypeOption = $input->getOption('cpu-type');
+ $autoscalingIndicator = '(A)';
+ $hasAutoscalingIndicator = false;
foreach ($services as $name => $service) {
$properties = $service->getProperties();
+ if (!$this->table->formatIsMachineReadable() && !empty($autoscalingEnabled[$name])) {
+ $name .= ' ' . $autoscalingIndicator;
+ $hasAutoscalingIndicator = true;
+ }
$row = [
'service' => $name,
'type' => $this->propertyFormatter->format($service->type, 'service_type'),
@@ -108,17 +128,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int
'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'] = $profileInfo['cpu_type'] ?? '';
$row['cpu'] = isset($profileInfo['cpu']) ? $this->resourcesUtil->formatCPU($profileInfo['cpu']) : '';
$row['memory'] = isset($profileInfo['cpu']) ? $profileInfo['memory'] : '';
}
-
if (!empty($properties['resources'])) {
foreach ($properties['resources'] as $key => $value) {
if (isset($row[$key])) {
@@ -145,7 +170,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->table->render($rows, $this->tableHeader, $this->defaultColumns);
if (!$this->table->formatIsMachineReadable()) {
+ if ($hasAutoscalingIndicator) {
+ $this->stdErr->writeln($autoscalingIndicator . ' - Indicates that the service has autoscaling enabled');
+ }
+
$executable = $this->config->getStr('application.executable');
+
$isOriginalCommand = $input instanceof ArgvInput;
if ($isOriginalCommand) {
$this->stdErr->writeln('');
diff --git a/src/Command/Resources/ResourcesSetCommand.php b/src/Command/Resources/ResourcesSetCommand.php
index 887d85175..6d76c6597 100644
--- a/src/Command/Resources/ResourcesSetCommand.php
+++ b/src/Command/Resources/ResourcesSetCommand.php
@@ -76,6 +76,8 @@ protected function configure(): void
sprintf('Profile sizes are predefined CPU & memory values that can be viewed by running: %s resources:sizes', $this->config->getStr('application.executable')),
'',
'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->getStr('application.executable')),
];
if ($this->config->has('service.resources_help_url')) {
$helpLines[] = '';
@@ -122,11 +124,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$instanceLimit = $projectInfo['capabilities']['instance_limit'];
}
+ $autoscalingEnabled = [];
+ // 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.
[$givenSizes, $errored] = $this->parseSetting($input, 'size', $services, fn($v, $serviceName, $service) => $this->validateProfileSize($v, $serviceName, $service, $nextDeployment));
// Validate the --count option.
- [$givenCounts, $countErrored] = $this->parseSetting($input, 'count', $services, fn($v, $serviceName, $service) => $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit));
+ [$givenCounts, $countErrored] = $this->parseSetting($input, 'count', $services, fn($v, $serviceName, $service) => $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit, !empty($autoscalingEnabled[$serviceName])));
$errored = $errored || $countErrored;
// Validate the --disk option.
@@ -146,6 +157,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$containerProfiles = $nextDeployment->container_profiles;
+ // Remove guaranteed profiles if project does not support it.
+ $supportsGuaranteedCPU = $this->api->supportsGuaranteedCPU($selection->getProject(), $nextDeployment);
+ 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') === []
@@ -154,6 +175,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$updates = [];
$current = [];
+ $hasGuaranteedCPU = false;
foreach ($services as $name => $service) {
$type = $this->typeName($service);
$group = $this->group($service);
@@ -201,13 +223,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
|| (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;
}
@@ -225,8 +248,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$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
+ // 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']))) {
@@ -235,7 +269,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
} elseif ($showCompleteForm) {
$ensureHeader();
$default = $properties['instance_count'] ?: 1;
- $instanceCount = $this->questionHelper->askInput('Enter the number of instances', $default, [], fn($v) => $this->validateInstanceCount($v, $name, $service, $instanceLimit));
+ $instanceCount = $this->questionHelper->askInput(
+ 'Enter the number of instances',
+ $default,
+ [],
+ fn($v) => $this->validateInstanceCount($v, $name, $service, $instanceLimit, false)
+ );
if ($instanceCount !== $properties['instance_count']) {
$updates[$group][$name]['instance_count'] = $instanceCount;
}
@@ -334,7 +373,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
$this->stdErr->writeln('');
- if (!$this->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->getStr('warnings.guaranteed_resources_msg'))
+ . "\n\n" . "Are you sure you want to continue?";
+ }
+ if (!$this->questionHelper->confirm($questionText)) {
return 1;
}
@@ -385,10 +430,11 @@ private function summarizeChangesPerService(string $name, WebApp|Worker|Service
if (isset($updates['resources']['profile_size'])) {
$sizeInfo = $this->resourcesUtil->sizeInfo($properties, $containerProfiles);
$newProperties = array_replace_recursive($properties, $updates);
+
$newSizeInfo = $this->resourcesUtil->sizeInfo($newProperties, $containerProfiles);
$this->stdErr->writeln(' CPU: ' . $this->resourcesUtil->formatChange(
- $this->resourcesUtil->formatCPU($sizeInfo ? $sizeInfo['cpu'] : null),
- $this->resourcesUtil->formatCPU($newSizeInfo['cpu']),
+ $this->resourcesUtil->formatCPU($sizeInfo ? $sizeInfo['cpu'] : null) . ' ' . $this->formatCPUType($sizeInfo),
+ $this->resourcesUtil->formatCPU($newSizeInfo['cpu']) . ' ' . $this->formatCPUType($newSizeInfo)
));
$this->stdErr->writeln(' Memory: ' . $this->resourcesUtil->formatChange(
$sizeInfo ? $sizeInfo['memory'] : null,
@@ -444,11 +490,14 @@ protected function typeName(WebApp|Worker|Service $service): string
*
* @throws InvalidArgumentException
*/
- protected function validateInstanceCount(string $value, string $serviceName, WebApp|Worker|Service $service, ?int $limit): int
+ protected function validateInstanceCount(string $value, string $serviceName, WebApp|Worker|Service $service, ?int $limit, bool $autoscalingEnabled): int
{
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));
diff --git a/src/Command/Resources/ResourcesSizeListCommand.php b/src/Command/Resources/ResourcesSizeListCommand.php
index c5a777e29..33b78279b 100644
--- a/src/Command/Resources/ResourcesSizeListCommand.php
+++ b/src/Command/Resources/ResourcesSizeListCommand.php
@@ -19,7 +19,16 @@
class ResourcesSizeListCommand extends ResourcesCommandBase
{
/** @var array */
- protected array $tableHeader = ['size' => 'Size name', 'cpu' => 'CPU', 'memory' => 'Memory (MB)'];
+ protected array $tableHeader = [
+ 'size' => 'Size name',
+ 'cpu' => 'CPU',
+ 'memory' => 'Memory (MB)',
+ 'cpu_type' => 'CPU type',
+ ];
+
+ /** @var string[] */
+ protected array $defaultColumns = ['size', 'cpu', 'memory'];
+
public function __construct(private readonly Api $api, private readonly QuestionHelper $questionHelper, private readonly ResourcesUtil $resourcesUtil, private readonly Selector $selector, private readonly Table $table)
{
parent::__construct();
@@ -33,7 +42,7 @@ protected function configure(): void
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->addCompleter($this->selector);
- Table::configureInput($this->getDefinition(), $this->tableHeader);
+ Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns);
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -57,7 +66,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$servicesByProfile[$service->container_profile][] = $name;
}
- $containerProfiles = $nextDeployment->container_profiles;
+ $containerProfiles = $this->sortContainerProfiles($nextDeployment->container_profiles);
if ($serviceOption = $input->getOption('service')) {
if (!isset($services[$serviceOption])) {
@@ -86,8 +95,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
$rows = [];
+ $supportsGuaranteedCPU = $this->api->supportsGuaranteedCPU($selection->getProject(), $nextDeployment);
+ $defaultColumns = $this->defaultColumns;
+ if ($supportsGuaranteedCPU) {
+ $defaultColumns[] = 'cpu_type';
+ }
foreach ($containerProfiles[$profile] as $sizeName => $sizeInfo) {
- $rows[] = ['size' => $sizeName, 'cpu' => $this->resourcesUtil->formatCPU($sizeInfo['cpu']), 'memory' => $sizeInfo['memory']];
+ if (!$supportsGuaranteedCPU && $sizeInfo['cpu_type'] === 'guaranteed') {
+ continue;
+ }
+ $rows[] = [
+ 'size' => $sizeName,
+ 'cpu' => $this->resourcesUtil->formatCPU($sizeInfo['cpu']),
+ 'memory' => $sizeInfo['memory'],
+ 'cpu_type' => $sizeInfo['cpu_type'] ?? '',
+ ];
}
if (!$this->table->formatIsMachineReadable()) {
@@ -103,7 +125,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}
- $this->table->render($rows, $this->tableHeader);
+ $this->table->render($rows, $this->tableHeader, $defaultColumns);
return 0;
}
diff --git a/src/Command/Server/ServerCommandBase.php b/src/Command/Server/ServerCommandBase.php
index 3ddd4cb09..d934b1e91 100644
--- a/src/Command/Server/ServerCommandBase.php
+++ b/src/Command/Server/ServerCommandBase.php
@@ -346,8 +346,7 @@ protected function openLog(string $logFile): false|OutputInterface
*/
private function getRoutesList(string $projectRoot, string $address): array
{
- $localProject = $this->localProject;
- $routesConfig = (array) $localProject->readProjectConfigFile($projectRoot, 'routes.yaml');
+ $routesConfig = (array) $this->localProject->readProjectConfigFile($projectRoot, 'routes.yaml');
$routes = [];
foreach ($routesConfig as $route => $config) {
diff --git a/src/Command/SubscriptionInfoCommand.php b/src/Command/SubscriptionInfoCommand.php
index 905ca5582..18e5c8c6b 100644
--- a/src/Command/SubscriptionInfoCommand.php
+++ b/src/Command/SubscriptionInfoCommand.php
@@ -51,7 +51,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$id = (string) $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/Local/ApplicationFinder.php b/src/Local/ApplicationFinder.php
index 891effaa6..66eb2cc89 100644
--- a/src/Local/ApplicationFinder.php
+++ b/src/Local/ApplicationFinder.php
@@ -120,7 +120,7 @@ private function findGroupedApplications(string $directory): array
/**
* 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/ActivityLoader.php b/src/Service/ActivityLoader.php
index 205c7e37e..ee49be51c 100644
--- a/src/Service/ActivityLoader.php
+++ b/src/Service/ActivityLoader.php
@@ -171,6 +171,7 @@ public static function getAvailableTypes(): array
'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 d81e04f48..cb3a30c1e 100644
--- a/src/Service/ActivityMonitor.php
+++ b/src/Service/ActivityMonitor.php
@@ -156,7 +156,8 @@ public function waitAndLog(Activity $activity, int $pollInterval = 3, bool|strin
// 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;
@@ -541,13 +542,8 @@ public function waitMultiple(array $activities, Project $project, bool $context
/**
* Prints the result of an activity: success, failure, or cancelled.
- *
- * @param Activity $activity
- * @param bool $logOnFailure
- *
- * @return bool Success or failure.
*/
- private function printResult(Activity $activity, bool $logOnFailure = false): bool
+ private function printResult(Activity $activity, bool $logOnFailure = false): void
{
$stdErr = $this->stdErr;
@@ -555,23 +551,27 @@ private function printResult(Activity $activity, bool $logOnFailure = false): bo
switch ($activity->result) {
case Activity::RESULT_SUCCESS:
$stdErr->writeln('The activity succeeded: ' . self::getFormattedDescription($activity, true, true, 'green'));
- return true;
-
+ break;
case Activity::RESULT_FAILURE:
if ($activity->state === Activity::STATE_CANCELLED) {
$stdErr->writeln('The activity was cancelled: ' . self::getFormattedDescription($activity, true, true, 'yellow'));
- return false;
+ 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())));
}
- return false;
-
+ break;
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 env:deploy',
+ $this->config->getStr('application.executable'),
+ ));
}
}
diff --git a/src/Service/Api.php b/src/Service/Api.php
index 35b5e4723..d96c106a3 100644
--- a/src/Service/Api.php
+++ b/src/Service/Api.php
@@ -4,6 +4,10 @@
namespace Platformsh\Cli\Service;
+use Platformsh\Client\Model\Deployment\Service;
+use Platformsh\Client\Model\Deployment\WebApp;
+use Platformsh\Client\Model\Deployment\Worker;
+use Platformsh\Client\Model\Project\Capabilities;
use GuzzleHttp\HandlerStack;
use Symfony\Component\Filesystem\Filesystem;
use GuzzleHttp\Exception\RequestException;
@@ -30,6 +34,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\Environment;
@@ -625,7 +630,17 @@ public function getStreamContext(int|float $timeout = 15)
*/
private function matchesVendorFilter(string|array|null $filters, BasicProjectInfo $project): bool
{
- return empty($filters) || in_array($project->vendor, (array) $filters);
+ if (empty($filters)) {
+ return true;
+ }
+ if (in_array($project->vendor, (array) $filters)) {
+ return true;
+ }
+ // Show projects with the "upsun" vendor under the "platformsh" filter.
+ if ($project->vendor === 'upsun' && in_array('platformsh', (array) $filters)) {
+ return true;
+ }
+ return false;
}
/**
@@ -1368,7 +1383,10 @@ public function getSiteUrl(Environment $environment, string $appName, ?Environme
// 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;
@@ -1397,14 +1415,13 @@ private function on403(RequestInterface $request): void
*
* @param string $id
* @param Project|null $project
- * @param bool $forWrite
*
* @throws RequestException
*
* @return false|Subscription
* The subscription or false if not found.
*/
- public function loadSubscription(string $id, ?Project $project = null, bool $forWrite = true): Subscription|false
+ public function loadSubscription(string $id, ?Project $project = null): Subscription|false
{
$organizations_enabled = $this->config->getBool('api.organizations');
if (!$organizations_enabled) {
@@ -1413,25 +1430,6 @@ public function loadSubscription(string $id, ?Project $project = null, bool $for
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()->getStatusCode() !== 403) {
- throw $e;
- }
- $subscription = false;
- }
- if ($subscription) {
- $this->io->debug('Loaded the subscription directly');
- return $subscription;
- }
- }
-
// Use the project's organization, if known.
$organizationId = null;
if (isset($project)) {
@@ -1635,16 +1633,62 @@ public function supportsSizingApi(Project $project, ?EnvironmentDeployment $depl
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.
+ */
+ public function supportsAutoscaling(Project $project): bool
+ {
+ $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, bool $refresh = false): array
+ {
$cacheKey = 'project-settings:' . $project->id;
$cachedSettings = $this->cache->fetch($cacheKey);
- if (!empty($cachedSettings['sizing_api_enabled'])) {
- return true;
+ if (!empty($cachedSettings) && !$refresh) {
+ return $cachedSettings;
}
$request = new Request('GET', $project->getUri() . '/settings');
$response = $this->getHttpClient()->send($request);
$settings = (array) Utils::jsonDecode((string) $response->getBody(), true);
$this->cache->save($cacheKey, $settings, $this->config->getInt('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 Capabilities
+ */
+ public function getProjectCapabilities(Project $project, bool $refresh = false): Capabilities
+ {
+ $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->getInt('api.projects_ttl'));
+ return $capabilities;
}
/**
@@ -1696,6 +1740,83 @@ public function showSessionInfo(bool $logout = false, bool $newline = true): voi
}
}
+ /**
+ * 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): string|false
+ {
+ $rel = "#autoscaling";
+ if ($manage === true) {
+ $rel = "#manage-autoscaling";
+ }
+
+ if (!$environment->hasLink($rel)) {
+ $this->io->debug(\sprintf(
+ 'The environment %s is missing the link %s',
+ $environment->id,
+ $rel
+ ));
+
+ return false;
+ }
+
+ return $environment->getLink($rel);
+ }
+
+ /**
+ * Returns the autoscaling settings for the selected environment.
+ *
+ * @param Environment $environment
+ *
+ * @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): void
+ {
+ if (!$this->getAutoscalingSettingsLink($environment, true)) {
+ throw new EnvironmentStateException('Managing autoscaling settings is not currently available', $environment);
+ }
+
+ try {
+ $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;
+ }
+ }
+
/**
* Warn the user if a project is suspended.
*
@@ -1734,4 +1855,64 @@ public function redeployWarning(): void
'To redeploy an environment, run: ' . $this->config->getStr('application.executable') . ' redeploy',
]);
}
+
+ /**
+ * Lists services in a deployment.
+ *
+ * @param EnvironmentDeployment $deployment
+ *
+ * @return array
+ * An array of services keyed by the service name.
+ */
+ private function allServices(EnvironmentDeployment $deployment): array
+ {
+ $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 if a project supports guaranteed resources.
+ */
+ public function supportsGuaranteedCPU(Project $project, ?EnvironmentDeployment $deployment = null): bool
+ {
+ if ($deployment && ($info = $deployment->getProperty('project_info', false))) {
+ $settings = $info['settings'];
+ $capabilities = $info['capabilities'];
+ } else {
+ $settings = $this->getProjectSettings($project);
+ $capabilities = $this->getProjectCapabilities($project);
+ }
+
+ return !empty($settings['enable_guaranteed_resources']) && !empty($capabilities['guaranteed_resources']['enabled']);
+ }
+
+ /**
+ * Check if an environment has guaranteed CPU.
+ */
+ public function environmentHasGuaranteedCPU(Environment $environment, ?Project $project = null): bool
+ {
+ if (!$this->supportsGuaranteedCPU($project)) {
+ return false;
+ }
+
+ $deployment = $this->getCurrentDeployment($environment);
+ $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;
+ }
}
diff --git a/src/Service/Config.php b/src/Service/Config.php
index 93796149d..af57af930 100644
--- a/src/Service/Config.php
+++ b/src/Service/Config.php
@@ -455,6 +455,11 @@ private function applyEnvironmentOverrides(): void
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);
+ }
}
/**
diff --git a/src/Service/CurlCli.php b/src/Service/CurlCli.php
index e19a77f7d..3e4d5c499 100644
--- a/src/Service/CurlCli.php
+++ b/src/Service/CurlCli.php
@@ -28,7 +28,7 @@ public static function configureInput(InputDefinition $definition): void
$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)'));
}
@@ -50,12 +50,6 @@ public function run(string $baseUrl, InputInterface $input, OutputInterface $out
}
$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();
@@ -146,6 +140,11 @@ private function buildCurlCommand(string $url, string $token, InputInterface $in
}
}
+ // Set --fail-with-body by default.
+ if (!$input->getOption('fail')) {
+ $commandline .= ' --fail-with-body';
+ }
+
if ($requestMethod = $input->getOption('request')) {
$commandline .= ' --request ' . escapeshellarg((string) $requestMethod);
}
diff --git a/src/Service/Relationships.php b/src/Service/Relationships.php
index 6adebb944..6f82a458a 100644
--- a/src/Service/Relationships.php
+++ b/src/Service/Relationships.php
@@ -397,8 +397,8 @@ public function buildUrl(array $instance): string
* 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[]
*/
diff --git a/src/Service/ResourcesUtil.php b/src/Service/ResourcesUtil.php
index 0c89f65a1..62c38968d 100644
--- a/src/Service/ResourcesUtil.php
+++ b/src/Service/ResourcesUtil.php
@@ -185,21 +185,30 @@ public function sizeInfo(array $properties, array $containerProfiles): ?array
* @param int|float|string|null $previousValue
* @param int|float|string|null $newValue
* @param string $suffix A unit suffix e.g. ' MB'
+ * @param callable|null $comparator
*
* @return string
*/
- public function formatChange(int|float|string|null $previousValue, int|float|string|null $newValue, string $suffix = ''): string
+ public function formatChange(int|float|string|null $previousValue, int|float|string|null $newValue, string $suffix = '', ?callable $comparator = null): string
{
if ($previousValue === null || $newValue === $previousValue) {
return sprintf('%s%s', $newValue, $suffix);
}
+ if ($comparator !== null) {
+ $changeText = $comparator($previousValue, $newValue) ? 'increasing>' : 'decreasing>';
+ } elseif ($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',
- $newValue > $previousValue ? 'increasing>' : 'decreasing>',
+ $changeText,
$previousValue,
$suffix,
$newValue,
- $suffix,
+ $suffix
);
}
diff --git a/src/Service/Url.php b/src/Service/Url.php
index 6b3005581..cb0f712f0 100644
--- a/src/Service/Url.php
+++ b/src/Service/Url.php
@@ -82,17 +82,12 @@ public function openUrl(string $url, bool $print = true): bool
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) !== false;
}
// Print the URL.
diff --git a/src/Service/VariableCommandUtil.php b/src/Service/VariableCommandUtil.php
index 5053ad3b7..31d871075 100644
--- a/src/Service/VariableCommandUtil.php
+++ b/src/Service/VariableCommandUtil.php
@@ -110,9 +110,6 @@ public function getExistingVariable(string $name, Selection $selection, ?string
*/
public function displayVariable(ApiResourceBase $variable): void
{
- $table = $this->table;
- $formatter = $this->propertyFormatter;
-
$properties = $variable->getProperties();
$properties['level'] = $this->getVariableLevel($variable);
@@ -123,9 +120,9 @@ public function displayVariable(ApiResourceBase $variable): void
if ($key === 'value') {
$value = wordwrap((string) $value, 80, "\n", true);
}
- $values[] = $formatter->format($value, $key);
+ $values[] = $this->propertyFormatter->format($value, $key);
}
- $table->renderSimple($values, $headings);
+ $this->table->renderSimple($values, $headings);
}
public function getVariableLevel(ApiResourceBase $variable): string