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