From 36c414537a1dc485466340839a4d0c09deb5fd34 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 16:36:56 +0100 Subject: [PATCH 1/2] fix(resources): check trial resource limits via the organization trial API GET /organizations/{id}/profile returns 403 Forbidden for organizations that have moved to a new billing system, which made resources:set fail before applying any changes. The profile was only fetched to check the trial resources_limit, which no longer exists there. Trial resource limits have moved to GET /organizations/{id}/trial, regardless of the organization's billing system. Current usage totals come from GET /organizations/{id}/usage. The check now uses those endpoints: it applies when the organization has an active trial, and individual limits may be null, meaning the resource is not capped. Errors from the trial and usage requests are tolerated because the check is advisory; limits are enforced by the platform. This also means trials being unavailable (e.g. for other vendors) is not an error. Adds integration tests covering: no trial (the gated profile endpoint must not be requested), an active trial exceeding limits, an active trial within limits, null (uncapped) limits, and a vendor without trials. Written with Claude Code. Co-Authored-By: Claude Fable 5 --- integration-tests/resources_set_test.go | 10 +- integration-tests/resources_set_trial_test.go | 269 ++++++++++++++++++ .../Command/Resources/ResourcesSetCommand.php | 59 +++- 3 files changed, 319 insertions(+), 19 deletions(-) create mode 100644 integration-tests/resources_set_trial_test.go 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..d7ba17bd 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,45 @@ 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 (cpu, memory, storage). + * 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. From cd889815c138c16cd434579ed9cef342fb4d6e05 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 18:15:01 +0100 Subject: [PATCH 2/2] docs(resources): correct trialResourceLimits docblock The returned arrays are the raw resource_limit and usage totals objects, which contain more keys than cpu, memory and storage. Written with Claude Code. Co-Authored-By: Claude Fable 5 --- legacy/src/Command/Resources/ResourcesSetCommand.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/legacy/src/Command/Resources/ResourcesSetCommand.php b/legacy/src/Command/Resources/ResourcesSetCommand.php index d7ba17bd..31ba953f 100644 --- a/legacy/src/Command/Resources/ResourcesSetCommand.php +++ b/legacy/src/Command/Resources/ResourcesSetCommand.php @@ -746,10 +746,11 @@ private function formatErrors(array $errors, string $optionName): array /** * Returns the organization's trial resource limits and current usage totals. * - * Both are arrays of amounts keyed by resource type (cpu, memory, storage). - * 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. + * 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} */