Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions integration-tests/resources_set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
269 changes: 269 additions & 0 deletions integration-tests/resources_set_trial_test.go
Original file line number Diff line number Diff line change
@@ -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{
Comment thread
pjcdawkins marked this conversation as resolved.
"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")
}
60 changes: 48 additions & 12 deletions legacy/src/Command/Resources/ResourcesSetCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -344,39 +346,33 @@ 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: <comment>%s</comment>.',
$limit['cpu'],
));
$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: <comment>%sGB</comment>.',
$limit['memory'],
));
$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: <comment>%sGB</comment>.',
$limit['storage'],
Expand Down Expand Up @@ -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<string, int|float|null>, 1: ?array<string, int|float|null>}
*/
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.
Expand Down