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.