diff --git a/integration-tests/resources_set_test.go b/integration-tests/resources_set_test.go index 122d4fcd..923c3afe 100644 --- a/integration-tests/resources_set_test.go +++ b/integration-tests/resources_set_test.go @@ -69,13 +69,9 @@ func TestResourcesSet_CurrentSizeMissingFromContainerProfiles(t *testing.T) { }) }) - // Organization profile without a resources_limit: skips the - // trial-limit branch that would otherwise reach into - // $current['sizes'] (a separate nullable path not under test here). - apiHandler.Get("/organizations/"+orgID+"/profile", func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{}) - }) - + // No trial endpoint is mocked: the trial-limit branch that would + // otherwise reach into $current['sizes'] is skipped (a separate + // nullable path not under test here). nextPath := "/projects/" + projectID + "/environments/main/deployments/next" apiHandler.Get(nextPath, func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/integration-tests/resources_set_trial_test.go b/integration-tests/resources_set_trial_test.go new file mode 100644 index 00000000..c3d502c5 --- /dev/null +++ b/integration-tests/resources_set_trial_test.go @@ -0,0 +1,269 @@ +package tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +// setUpResourcesSetOrg configures an org, project, environment and deployment, +// ready for a resources:set --size change from the current 0.5. +func setUpResourcesSetOrg(apiHandler *mockapi.Handler, orgID string) (projectID string) { + myUserID := "my-user-id" + apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) + apiHandler.SetOrgs([]*mockapi.Org{{ + ID: orgID, + Type: "flexible", + Name: "acme", + Label: "Acme", + Owner: myUserID, + Capabilities: []string{}, + Links: mockapi.MakeHALLinks( + "self=/organizations/" + url.PathEscape(orgID), + ), + }}) + + projectID = mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Organization: orgID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + apiHandler.SetEnvironments([]*mockapi.Environment{ + makeEnv(projectID, "main", "production", "active", nil), + }) + + apiHandler.Get("/projects/"+projectID+"/settings", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "sizing_api_enabled": true, + }) + }) + + nextDeploymentPath := "/projects/" + projectID + "/environments/main/deployments/next" + apiHandler.Get(nextDeploymentPath, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "webapps": map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "BALANCED", + "resources": map[string]any{ + "profile_size": "0.5", + }, + "instance_count": 1, + "disk": 512, + }, + }, + "services": map[string]any{}, + "workers": map[string]any{}, + "routes": map[string]any{}, + "project_info": map[string]any{ + "settings": map[string]any{}, + "capabilities": map[string]any{}, + }, + "container_profiles": map[string]any{ + "BALANCED": map[string]any{ + "0.1": map[string]any{ + "cpu": "0.1", + "memory": "64", + "cpu_type": "shared", + }, + "0.5": map[string]any{ + "cpu": "0.5", + "memory": "128", + "cpu_type": "shared", + }, + "1": map[string]any{ + "cpu": "1", + "memory": "256", + "cpu_type": "shared", + }, + }, + }, + }) + }) + + return projectID +} + +// setTrial configures the org's trial endpoint response. A nil trial means no +// trial (the endpoint returns {"trial": null}). +func setTrial(apiHandler *mockapi.Handler, orgID string, trial map[string]any) { + apiHandler.Get("/organizations/"+orgID+"/trial", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "trial": trial, + "_links": map[string]any{ + "self": map[string]any{"href": "/organizations/" + orgID + "/trial"}, + }, + }) + }) +} + +// activeTrial returns an active trial with the given resource limits, which +// may be nil (not capped) as well as numeric. +func activeTrial(cpu, memory, storage any) map[string]any { + return map[string]any{ + "status": "active", + "model": "upsun_extended", + "resource_limit": map[string]any{ + "projects": 1, + "environments": 2, + "cpu": cpu, + "memory": memory, + "storage": storage, + }, + } +} + +// setUsage configures the org's usage endpoint response, and returns a +// pointer to a counter of requests to the endpoint. +func setUsage(apiHandler *mockapi.Handler, orgID string, cpu, memory, storage float64) *int { + requests := new(int) + apiHandler.Get("/organizations/"+orgID+"/usage", func(w http.ResponseWriter, _ *http.Request) { + *requests++ + _ = json.NewEncoder(w).Encode(map[string]any{ + "org": map[string]any{}, + "projects": []any{}, + "totals": map[string]any{ + "cpu": cpu, + "memory": memory, + "storage": storage, + "projects": 1, + }, + }) + }) + return requests +} + +func runResourcesSet( + t *testing.T, apiHandler *mockapi.Handler, projectID, size string, +) (stdout, stderr string, err error) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + + return f.RunCombinedOutput( + "resources:set", + "-p", projectID, + "-e", "main", + "--size", size, + "--dry-run", + "--no-wait", + ) +} + +// TestResourcesSet_NoTrial checks that resources:set succeeds for an +// organization without a trial, and no longer fetches the organization +// profile (whose endpoint returns 403 since the billing system migration). +func TestResourcesSet_NoTrial(t *testing.T) { + apiHandler := mockapi.NewHandler(t) + orgID := "org-no-trial" + projectID := setUpResourcesSetOrg(apiHandler, orgID) + setTrial(apiHandler, orgID, nil) + + // The profile endpoint is gated for migrated organizations; the command + // must not request it (the run fails if it does). + apiHandler.Get("/organizations/"+orgID+"/profile", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "title": "This organization profile has moved to the billing profile.", + "status": 403, + "detail": "Forbidden.", + }) + }) + + stdout, stderr, err := runResourcesSet(t, apiHandler, projectID, "app:0.1") + + assert.NotContains(t, stderr, "Permission denied") + assert.NotContains(t, stderr, "403 Forbidden") + require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) + assert.Contains(t, stderr+stdout, "Summary of changes") +} + +// TestResourcesSet_TrialLimitExceeded checks that the resource limits of an +// active trial are enforced. +func TestResourcesSet_TrialLimitExceeded(t *testing.T) { + apiHandler := mockapi.NewHandler(t) + orgID := "org-trial-exceeded" + projectID := setUpResourcesSetOrg(apiHandler, orgID) + setTrial(apiHandler, orgID, activeTrial(0.5, 10, 10)) + // The CPU limit is already used up, so increasing the app size from 0.5 + // to 1 CPU must be refused. + setUsage(apiHandler, orgID, 0.5, 0.125, 0.5) + + _, stderr, err := runResourcesSet(t, apiHandler, projectID, "app:1") + + assert.Error(t, err) + assert.Contains(t, stderr, "trial CPU limit") +} + +// TestResourcesSet_TrialWithinLimits checks that changes within an active +// trial's resource limits are allowed. +func TestResourcesSet_TrialWithinLimits(t *testing.T) { + apiHandler := mockapi.NewHandler(t) + orgID := "org-trial-within" + projectID := setUpResourcesSetOrg(apiHandler, orgID) + setTrial(apiHandler, orgID, activeTrial(4.5, 12, 20)) + usageRequests := setUsage(apiHandler, orgID, 0.5, 0.125, 0.5) + + stdout, stderr, err := runResourcesSet(t, apiHandler, projectID, "app:1") + + require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) + assert.Contains(t, stderr+stdout, "Summary of changes") + assert.Equal(t, 1, *usageRequests, "the limit check must have run") +} + +// TestResourcesSet_TrialNullCaps checks that null resource limits are treated +// as not capped, while the other limits are still enforced. +func TestResourcesSet_TrialNullCaps(t *testing.T) { + apiHandler := mockapi.NewHandler(t) + orgID := "org-trial-null-caps" + projectID := setUpResourcesSetOrg(apiHandler, orgID) + // CPU and storage are not capped; the memory cap is already used up. + setTrial(apiHandler, orgID, activeTrial(nil, 10, nil)) + setUsage(apiHandler, orgID, 0.5, 10, 0.5) + + _, stderr, err := runResourcesSet(t, apiHandler, projectID, "app:1") + + assert.Error(t, err) + assert.NotContains(t, stderr, "trial CPU limit") + assert.Contains(t, stderr, "trial memory limit") +} + +// TestResourcesSet_TrialsNotEnabled checks that an error from the trial +// endpoint is tolerated, e.g. for vendors without trials. +func TestResourcesSet_TrialsNotEnabled(t *testing.T) { + apiHandler := mockapi.NewHandler(t) + orgID := "org-trial-disabled" + projectID := setUpResourcesSetOrg(apiHandler, orgID) + + apiHandler.Get("/organizations/"+orgID+"/trial", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "title": "Trials are not enabled.", + "status": 403, + "detail": "Forbidden.", + }) + }) + + stdout, stderr, err := runResourcesSet(t, apiHandler, projectID, "app:0.1") + + require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) + assert.Contains(t, stderr+stdout, "Summary of changes") +} diff --git a/legacy/src/Command/Resources/ResourcesSetCommand.php b/legacy/src/Command/Resources/ResourcesSetCommand.php index bfc7001b..31ba953f 100644 --- a/legacy/src/Command/Resources/ResourcesSetCommand.php +++ b/legacy/src/Command/Resources/ResourcesSetCommand.php @@ -15,11 +15,13 @@ use Platformsh\Cli\Console\ArrayArgument; use Platformsh\Cli\Util\OsUtil; use Platformsh\Cli\Util\Wildcard; +use GuzzleHttp\Exception\GuzzleException; use Platformsh\Client\Exception\EnvironmentStateException; use Platformsh\Client\Model\Deployment\EnvironmentDeployment; use Platformsh\Client\Model\Deployment\Service; use Platformsh\Client\Model\Deployment\WebApp; use Platformsh\Client\Model\Deployment\Worker; +use Platformsh\Client\Model\Project; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; @@ -344,23 +346,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); - $project = $selection->getProject(); - $organization = $this->api->getClient()->getOrganizationById($project->getProperty('organization')); - if (!$organization) { - throw new \RuntimeException('Failed to load project organization: ' . $project->getProperty('organization')); - } - $profile = $organization->getProfile(); - if ($input->getOption('force') === false && isset($profile->resources_limit) && $profile->resources_limit) { + [$limit, $used] = $input->getOption('force') === false ? $this->trialResourceLimits($selection->getProject()) : [null, null]; + if ($limit !== null && $used !== null) { $diff = $this->computeMemoryCPUStorageDiff($updates, $current); - $limit = $profile->resources_limit['limit']; - $used = $profile->resources_limit['used']['totals']; $this->io->debug('Raw diff: ' . json_encode($diff, JSON_UNESCAPED_SLASHES)); $this->io->debug('Raw limits: ' . json_encode($limit, JSON_UNESCAPED_SLASHES)); $this->io->debug('Raw used: ' . json_encode($used, JSON_UNESCAPED_SLASHES)); + // Each limit may be absent or null, meaning that resource is not capped. $errored = false; - if ($limit['cpu'] < $used['cpu'] + $diff['cpu']) { + if (isset($limit['cpu']) && $limit['cpu'] < ($used['cpu'] ?? 0) + $diff['cpu']) { $this->stdErr->writeln(sprintf( 'The requested resources will exceed your organization\'s trial CPU limit, which is: %s.', $limit['cpu'], @@ -368,7 +364,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $errored = true; } - if ($limit['memory'] < $used['memory'] + ($diff['memory'] / 1024)) { + if (isset($limit['memory']) && $limit['memory'] < ($used['memory'] ?? 0) + ($diff['memory'] / 1024)) { $this->stdErr->writeln(sprintf( 'The requested resources will exceed your organization\'s trial memory limit, which is: %sGB.', $limit['memory'], @@ -376,7 +372,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $errored = true; } - if ($limit['storage'] < $used['storage'] + ($diff['disk'] / 1024)) { + if (isset($limit['storage']) && $limit['storage'] < ($used['storage'] ?? 0) + ($diff['disk'] / 1024)) { $this->stdErr->writeln(sprintf( 'The requested resources will exceed your organization\'s trial storage limit, which is: %sGB.', $limit['storage'], @@ -747,6 +743,46 @@ private function formatErrors(array $errors, string $optionName): array return $ret; } + /** + * Returns the organization's trial resource limits and current usage totals. + * + * Both are arrays of amounts keyed by resource type, including cpu, + * memory and storage (as well as others such as projects). Individual + * limits may be absent or null, meaning the resource is not capped. + * Returns [null, null] if the organization has no active trial, or if + * the trial or usage information is unavailable. + * + * @return array{0: ?array, 1: ?array} + */ + private function trialResourceLimits(Project $project): array + { + $organization = $this->api->getOrganizationById($project->getProperty('organization')); + if (!$organization) { + throw new \RuntimeException('Failed to load project organization: ' . $project->getProperty('organization')); + } + $httpClient = $this->api->getHttpClient(); + try { + $data = (array) json_decode((string) $httpClient->request('GET', $organization->getUri() . '/trial')->getBody(), true); + } catch (GuzzleException $e) { + // Trials may not be enabled, e.g. for other vendors. + $this->io->debug('Ignoring error fetching the organization trial: ' . $e->getMessage()); + return [null, null]; + } + if (!isset($data['trial']['status'], $data['trial']['resource_limit']) || $data['trial']['status'] !== 'active') { + return [null, null]; + } + try { + $usage = (array) json_decode((string) $httpClient->request('GET', $organization->getUri() . '/usage')->getBody(), true); + } catch (GuzzleException $e) { + $this->io->debug('Ignoring error fetching the organization usage: ' . $e->getMessage()); + return [null, null]; + } + if (!isset($usage['totals'])) { + return [null, null]; + } + return [$data['trial']['resource_limit'], $usage['totals']]; + } + /** * Compute the total memory/CPU/storage diff that will occur when the given update * is applied.