From 151c792e7136f0a25c4030387625fe98a5a14183 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 27 Sep 2026 22:06:58 +0100 Subject: [PATCH 1/4] feat(resources:set): allow --count for horizontally scalable services Services such as database replicas can be scaled manually through the sizing API, but the CLI rejected any instance count for a service. Allow the instance count of a service when the deployment reports supports_horizontal_scaling for it, in both --count and the interactive form. Services without the flag now get a "does not support horizontal scaling" error. Tasks and the autoscaling check are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Command/Resources/ResourcesSetCommand.php | 28 ++++++++++++--- .../Command/Resources/ResourcesSetTest.php | 35 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/legacy/src/Command/Resources/ResourcesSetCommand.php b/legacy/src/Command/Resources/ResourcesSetCommand.php index a4b6916c6..4a970e1cf 100644 --- a/legacy/src/Command/Resources/ResourcesSetCommand.php +++ b/legacy/src/Command/Resources/ResourcesSetCommand.php @@ -53,7 +53,7 @@ protected function configure(): void 'count', 'C', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'Set the instance count of apps or workers.' + 'Set the instance count of apps, workers, or services that support horizontal scaling.' . "\nItems are in the format name:value as above.", ) ->addOption( @@ -104,6 +104,7 @@ protected function configure(): void $this->addExample('Set profile sizes for two apps and a service', '--size frontend:0.1,backend:.25,database:1'); $this->addExample('Give the "backend" app 3 instances', '--count backend:3'); + $this->addExample('Give the "database_replica" service 2 instances', '--count database_replica:2'); $this->addExample('Give 512 MB disk to the "backend" app and 2 GB to the "database" service', '--disk backend:512,database:2048'); $this->addExample('Give 524288 MB (512 GB) of object storage to the "backend" app', '--object-storage backend:524288'); $this->addExample('Set the same profile size for the "backend" and "frontend" apps using a wildcard', '--size ' . OsUtil::escapeShellArg('*end:0.1')); @@ -290,8 +291,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Set the instance count. - // This is not applicable to a Service or a Task, and unavailable when autoscaling is enabled. - if (!$service instanceof Service && !$service instanceof Task && empty($autoscalingEnabled[$name])) { + // This is not applicable to a Task or a non-scalable Service, and unavailable when autoscaling is enabled. + if ($this->supportsInstanceCount($service) && empty($autoscalingEnabled[$name])) { if (isset($givenCounts[$name])) { $instanceCount = $givenCounts[$name]; if ($instanceCount !== $properties['instance_count'] && !($instanceCount === 1 && !isset($properties['instance_count']))) { @@ -543,6 +544,22 @@ protected function typeName(WebApp|Worker|Service|Task $service): string return 'service'; } + /** + * Checks whether the instance count of a container can be set. + * + * Services are only scalable if the deployment says so. + */ + protected function supportsInstanceCount(WebApp|Worker|Service|Task $service): bool + { + if ($service instanceof Task) { + return false; + } + if ($service instanceof Service) { + return !empty($service->getProperties()['supports_horizontal_scaling']); + } + return true; + } + /** * Validates a given instance count. * @@ -550,9 +567,12 @@ protected function typeName(WebApp|Worker|Service|Task $service): string */ protected function validateInstanceCount(string $value, string $serviceName, WebApp|Worker|Service|Task $service, ?int $limit, bool $autoscalingEnabled): int { - if ($service instanceof Service || $service instanceof Task) { + if ($service instanceof Task) { throw new InvalidArgumentException(sprintf('The instance count of the %s %s cannot be changed.', $this->typeName($service), $serviceName)); } + if (!$this->supportsInstanceCount($service)) { + throw new InvalidArgumentException(sprintf('The %s %s does not support horizontal scaling.', $this->typeName($service), $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)); } diff --git a/legacy/tests/Command/Resources/ResourcesSetTest.php b/legacy/tests/Command/Resources/ResourcesSetTest.php index 7b1e6a0bd..3b6b8847f 100644 --- a/legacy/tests/Command/Resources/ResourcesSetTest.php +++ b/legacy/tests/Command/Resources/ResourcesSetTest.php @@ -8,7 +8,12 @@ use PHPUnit\Framework\TestCase; use Platformsh\Cli\Command\Resources\ResourcesSetCommand; use Platformsh\Cli\Tests\MockApp; +use Platformsh\Client\Model\Deployment\Service; +use Platformsh\Client\Model\Deployment\Task; +use Platformsh\Client\Model\Deployment\WebApp; +use Platformsh\Client\Model\Deployment\Worker; use Symfony\Component\Console\Command\LazyCommand; +use Symfony\Component\Console\Exception\InvalidArgumentException; #[Group('commands')] class ResourcesSetTest extends TestCase @@ -23,6 +28,36 @@ private function getCommandInstance(): ResourcesSetCommand return $command; } + public function testValidateInstanceCount(): void + { + $command = $this->getCommandInstance(); + $m = new \ReflectionMethod($command, 'validateInstanceCount'); + + $cases = [ + 'app' => [WebApp::fromData([]), false, '2', 2], + 'worker' => [Worker::fromData([]), false, '3', 3], + 'app with autoscaling' => [WebApp::fromData([]), true, '2', 'cannot be changed when autoscaling is enabled'], + 'task' => [Task::fromData([]), false, '2', 'cannot be changed'], + 'service without flag' => [Service::fromData([]), false, '2', 'does not support horizontal scaling'], + 'service not supporting' => [Service::fromData(['supports_horizontal_scaling' => false]), false, '2', 'does not support horizontal scaling'], + 'service supporting' => [Service::fromData(['supports_horizontal_scaling' => true]), false, '2', 2], + 'service supporting with autoscaling' => [Service::fromData(['supports_horizontal_scaling' => true]), true, '2', 'cannot be changed when autoscaling is enabled'], + 'service supporting over limit' => [Service::fromData(['supports_horizontal_scaling' => true]), false, '9', 'exceeds the limit 8'], + ]; + foreach ($cases as $name => [$service, $autoscalingEnabled, $value, $expected]) { + if (is_int($expected)) { + $this->assertSame($expected, $m->invoke($command, $value, 'foo', $service, 8, $autoscalingEnabled), $name); + continue; + } + try { + $m->invoke($command, $value, 'foo', $service, 8, $autoscalingEnabled); + $this->fail('Expected an exception: ' . $name); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString($expected, $e->getMessage(), $name); + } + } + } + /** * A container whose minimum disk is 0 supports a disk without needing one, * so it must not be treated as unconfigured. Asking about it is what made From 3e81cbaaaa541fd4aa54e712fa6e631b9f0b90a5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 27 Sep 2026 23:11:50 +0100 Subject: [PATCH 2/4] fix(resources:set): treat an unset instance count as 1 A service may have no instance_count. The interactive form then compared the accepted default of 1 against null and queued a no-op update. Co-Authored-By: Claude Opus 5.5 --- legacy/src/Command/Resources/ResourcesSetCommand.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/legacy/src/Command/Resources/ResourcesSetCommand.php b/legacy/src/Command/Resources/ResourcesSetCommand.php index 4a970e1cf..4bb9d326f 100644 --- a/legacy/src/Command/Resources/ResourcesSetCommand.php +++ b/legacy/src/Command/Resources/ResourcesSetCommand.php @@ -293,21 +293,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Set the instance count. // This is not applicable to a Task or a non-scalable Service, and unavailable when autoscaling is enabled. if ($this->supportsInstanceCount($service) && empty($autoscalingEnabled[$name])) { + // An unset instance count means 1. + $currentCount = $properties['instance_count'] ?? 1; if (isset($givenCounts[$name])) { $instanceCount = $givenCounts[$name]; - if ($instanceCount !== $properties['instance_count'] && !($instanceCount === 1 && !isset($properties['instance_count']))) { + if ($instanceCount !== $currentCount) { $updates[$group][$name]['instance_count'] = $instanceCount; } } elseif ($showCompleteForm) { $ensureHeader(); - $default = (string) ($properties['instance_count'] ?: 1); + $default = (string) ($currentCount ?: 1); $instanceCount = $this->questionHelper->askInput( 'Enter the number of instances', $default, [], fn($v) => $this->validateInstanceCount($v, $name, $service, $instanceLimit, false) ); - if ($instanceCount !== $properties['instance_count']) { + if ($instanceCount !== $currentCount) { $updates[$group][$name]['instance_count'] = $instanceCount; } } From c005a1fbe60902db92ef48e7948365b275a7571a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 27 Sep 2026 23:20:21 +0100 Subject: [PATCH 3/4] test(resources:set): cover options across all container types Add integration tests with a deployment holding an app, two workers (one autoscaled), three services (one horizontally scalable with no instance_count, one without a disk) and a task. They check the PATCH body or errors for --count, --size, --disk and --object-storage on each container type, wildcards, the instance limit, autoscaling, unknown containers, --service filtering and --dry-run, and that the interactive form asks for a count only for a scalable service. Co-Authored-By: Claude Opus 5.5 --- .../resources_set_containers_test.go | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 integration-tests/resources_set_containers_test.go diff --git a/integration-tests/resources_set_containers_test.go b/integration-tests/resources_set_containers_test.go new file mode 100644 index 000000000..bc85f5818 --- /dev/null +++ b/integration-tests/resources_set_containers_test.go @@ -0,0 +1,392 @@ +package tests + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +// setupResourcesSetContainers serves a project whose next deployment has an +// app, two workers, three services and a task. The "app--mail" worker has +// autoscaling enabled, and "replica" supports horizontal scaling but has no +// instance_count. It returns the command factory, the project ID and a +// holder for the deployment PATCH body. +func setupResourcesSetContainers(t *testing.T) (f *cmdFactory, projectID string, patchBody *atomic.Value) { + authServer := mockapi.NewAuthServer(t) + t.Cleanup(authServer.Close) + + myUserID := "my-user-id" + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) + + orgID := "org-id-1" + apiHandler.SetOrgs([]*mockapi.Org{{ + ID: orgID, + Type: "flexible", + Name: "acme", + Label: "Acme", + Owner: myUserID, + Capabilities: []string{}, + Links: mockapi.MakeHALLinks( + "self=/organizations/"+url.PathEscape(orgID), + "profile=/organizations/"+url.PathEscape(orgID)+"/profile", + ), + }}) + + projectID = mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Organization: orgID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + envPath := "/projects/" + projectID + "/environments/main" + autoscalingPath := envPath + "/autoscaling" + main := makeEnv(projectID, "main", "production", "active", nil) + main.Links["#autoscaling"] = mockapi.HALLink{HREF: autoscalingPath} + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + apiHandler.Get("/projects/"+projectID+"/settings", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"sizing_api_enabled": true}) + }) + apiHandler.Get("/organizations/"+orgID+"/profile", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + apiHandler.Get(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "services": map[string]any{ + "app": map[string]any{"enabled": false}, + "app--mail": map[string]any{"enabled": true}, + }, + "_links": mockapi.MakeHALLinks("self=" + autoscalingPath), + }) + }) + + disk := map[string]any{"minimum": map[string]any{"disk": 256}, "default": map[string]any{"disk": 512}} + withSize := func(size string, extra map[string]any) map[string]any { + r := map[string]any{"profile_size": size} + for k, v := range extra { + r[k] = v + } + return r + } + nextPath := envPath + "/deployments/next" + apiHandler.Get(nextPath, 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": withSize("0.5", disk), + "instance_count": 1, + "disk": 512, + }, + }, + "workers": map[string]any{ + "app--queue": map[string]any{ + "name": "app--queue", + "type": "golang:1.23", + "container_profile": "BALANCED", + "resources": withSize("0.5", nil), + "instance_count": 1, + }, + "app--mail": map[string]any{ + "name": "app--mail", + "type": "golang:1.23", + "container_profile": "BALANCED", + "resources": withSize("0.5", nil), + "instance_count": 2, + }, + }, + "services": map[string]any{ + "db": map[string]any{ + "type": "mariadb:11.4", + "container_profile": "BALANCED", + "resources": withSize("1", disk), + "instance_count": 1, + "disk": 1024, + "supports_horizontal_scaling": false, + }, + // No instance_count, which means 1. + "replica": map[string]any{ + "type": "mariadb-replica:11.4", + "container_profile": "BALANCED", + "resources": withSize("0.5", disk), + "disk": 1024, + "supports_horizontal_scaling": true, + }, + // No minimum disk, so no disk can be set. + "cache": map[string]any{ + "type": "redis:7.2", + "container_profile": "BALANCED", + "resources": withSize("0.5", nil), + "instance_count": 1, + }, + }, + "tasks": map[string]any{ + "cleanup": map[string]any{ + "name": "cleanup", + "type": "golang:1.23", + "resources": withSize("0.5", nil), + }, + }, + "routes": map[string]any{}, + "project_info": map[string]any{ + "settings": map[string]any{}, + "capabilities": map[string]any{"instance_limit": 4}, + }, + "container_profiles": map[string]any{ + "BALANCED": map[string]any{ + "0.5": map[string]any{"cpu": 0.5, "memory": 1024, "cpu_type": "shared"}, + "1": map[string]any{"cpu": 1, "memory": 2048, "cpu_type": "shared"}, + "2": map[string]any{"cpu": 2, "memory": 4096, "cpu_type": "shared"}, + }, + }, + "_links": mockapi.MakeHALLinks("self="+nextPath, "#edit="+nextPath), + }) + }) + + patchBody = &atomic.Value{} + apiHandler.Patch(nextPath, func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal(b, &body)) + patchBody.Store(body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "_embedded": map[string]any{"activities": []any{}}, + }) + }) + + apiServer := httptest.NewServer(apiHandler) + t.Cleanup(apiServer.Close) + + return newCommandFactory(t, apiServer.URL, authServer.URL), projectID, patchBody +} + +// TestResourcesSet_Containers checks resources:set options across apps, +// workers, services and tasks: the updates sent, and the validation errors. +func TestResourcesSet_Containers(t *testing.T) { + type patch map[string]map[string]map[string]any + + cases := []struct { + name string + args []string + // wantPatch is the expected PATCH body, or nil if none may be sent. + wantPatch patch + wantErr bool + wantOut []string + }{ + { + name: "app count", + args: []string{"--count", "app:3"}, + wantPatch: patch{"webapps": {"app": {"instance_count": 3}}}, + }, + { + name: "worker count", + args: []string{"--count", "app--queue:2"}, + wantPatch: patch{"workers": {"app--queue": {"instance_count": 2}}}, + }, + { + name: "scalable service count", + args: []string{"--count", "replica:2"}, + wantPatch: patch{"services": {"replica": {"instance_count": 2}}}, + wantOut: []string{"Summary of changes", "Instance count: increasing from 1 to 2"}, + }, + { + name: "count wildcard", + args: []string{"--count", "rep*:3"}, + wantPatch: patch{"services": {"replica": {"instance_count": 3}}}, + }, + { + name: "service without horizontal scaling", + args: []string{"--count", "db:2"}, + wantErr: true, + wantOut: []string{"The service db does not support horizontal scaling."}, + }, + { + name: "task count", + args: []string{"--count", "cleanup:2"}, + wantErr: true, + wantOut: []string{"The instance count of the task cleanup cannot be changed."}, + }, + { + name: "count with autoscaling", + args: []string{"--count", "app--mail:3"}, + wantErr: true, + wantOut: []string{"cannot be changed when autoscaling is enabled"}, + }, + { + name: "count over the instance limit", + args: []string{"--count", "replica:5"}, + wantErr: true, + wantOut: []string{"The instance count 5 exceeds the limit 4."}, + }, + { + name: "zero count", + args: []string{"--count", "app:0"}, + wantErr: true, + wantOut: []string{"Invalid instance count 0"}, + }, + { + name: "several count errors", + args: []string{"--count", "db:2,cache:2"}, + wantErr: true, + wantOut: []string{"Errors in --count values:", "The service db does not", "The service cache does not"}, + }, + { + name: "unchanged count", + args: []string{"--count", "app:1,replica:1"}, + wantOut: []string{"nothing to update"}, + }, + { + name: "sizes and disks together", + args: []string{"--size", "app:1,db:2,cleanup:1", "--disk", "db:2048"}, + wantPatch: patch{ + "webapps": {"app": {"resources": map[string]any{"profile_size": "1"}}}, + "services": {"db": {"resources": map[string]any{"profile_size": "2"}, "disk": 2048}}, + "tasks": {"cleanup": {"resources": map[string]any{"profile_size": "1"}}}, + }, + }, + { + name: "unknown size", + args: []string{"--size", "app:3"}, + wantErr: true, + wantOut: []string{"Size 3 not found in container profile BALANCED"}, + }, + { + name: "disk on a worker", + args: []string{"--disk", "app--queue:512"}, + wantErr: true, + wantOut: []string{"The worker app--queue does not support a persistent disk."}, + }, + { + name: "disk on a service without one", + args: []string{"--disk", "cache:512"}, + wantErr: true, + wantOut: []string{"The service cache does not support a persistent disk."}, + }, + { + name: "app object storage", + args: []string{"--object-storage", "app:1024"}, + wantPatch: patch{"webapps": {"app": {"resources": map[string]any{"disk": map[string]any{"object": 1024}}}}}, + }, + { + name: "service object storage", + args: []string{"--object-storage", "db:1024"}, + wantErr: true, + wantOut: []string{"Object storage is only available on apps; db is a service."}, + }, + { + name: "unknown container", + args: []string{"--count", "nope:2"}, + wantErr: true, + wantOut: []string{"Container nope not found."}, + }, + { + name: "missing value", + args: []string{"--count", "app"}, + wantErr: true, + wantOut: []string{`app is not valid; it must be in the format "name:value".`}, + }, + { + name: "filtered out by --service", + args: []string{"--service", "replica", "--size", "app:1"}, + wantErr: true, + wantOut: []string{"Container app not found."}, + }, + { + name: "dry run", + args: []string{"--count", "replica:2", "--dry-run"}, + wantOut: []string{"Summary of changes", "Instance count"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f, projectID, patchBody := setupResourcesSetContainers(t) + + args := append([]string{"resources:set", "-p", projectID, "-e", "main", "--no-wait", "--yes"}, c.args...) + stdout, stderr, err := f.RunCombinedOutput(args...) + if c.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) + } + for _, s := range c.wantOut { + assert.Contains(t, stderr, s) + } + + if c.wantPatch == nil { + assert.Nil(t, patchBody.Load(), "no deployment update should be sent") + return + } + want, err := json.Marshal(c.wantPatch) + require.NoError(t, err) + got, err := json.Marshal(patchBody.Load()) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) + }) + } +} + +// TestResourcesSet_ContainersInteractive checks the interactive form for +// services: the instance count is asked only for a scalable service, and +// accepting the defaults changes nothing. +func TestResourcesSet_ContainersInteractive(t *testing.T) { + cases := []struct { + name string + service string + input string + wantCount bool + wantPatch map[string]any + }{ + {name: "scalable service defaults", service: "replica", input: "\n\n\n", wantCount: true}, + { + name: "scalable service new count", service: "replica", input: "\n3\n\ny\n", wantCount: true, + wantPatch: map[string]any{"services": map[string]any{"replica": map[string]any{"instance_count": 3}}}, + }, + {name: "other service defaults", service: "db", input: "\n\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f, projectID, patchBody := setupResourcesSetContainers(t) + + stdout, stderr, err := f.RunInteractive( + c.input, + "resources:set", "-p", projectID, "-e", "main", "--no-wait", "--service", c.service, + ) + require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) + + if c.wantCount { + assert.Contains(t, stderr, "Enter the number of instances") + } else { + assert.NotContains(t, stderr, "Enter the number of instances") + } + + if c.wantPatch == nil { + assert.Contains(t, stderr, "nothing to update") + assert.Nil(t, patchBody.Load(), "no deployment update should be sent") + return + } + want, err := json.Marshal(c.wantPatch) + require.NoError(t, err) + got, err := json.Marshal(patchBody.Load()) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) + }) + } +} From 4ade4c344b9ab9c381957b49e1945558827f063a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Sun, 27 Sep 2026 23:51:24 +0100 Subject: [PATCH 4/4] test: move resources and autoscaling API mocks into mockapi Add to mockapi: - Environment.SetNextDeployment, serving the next deployment as raw data with self and #edit links, and recording PATCH bodies, which Handler.DeploymentPatches returns - Environment.SetAutoscalingSettings, serving the settings and adding the #autoscaling and #manage-autoscaling links - Project.Settings, served at the project's /settings path Use them in the resources and autoscaling integration tests, and merge the three resources setup helpers into setUpResourcesProject, with nextDeployment, serveAPI and deploymentPatch helpers. Remove the organization profile stubs: resources:set no longer requests the profile. Co-Authored-By: Claude Opus 5.5 --- integration-tests/autoscaling_enabled_test.go | 20 +- .../autoscaling_missing_defaults_test.go | 12 +- .../autoscaling_new_service_test.go | 28 +- .../autoscaling_settings_set_test.go | 55 ++-- .../autoscaling_validate_metric_test.go | 46 ++-- integration-tests/resources_get_test.go | 77 ++---- integration-tests/resources_helpers_test.go | 91 +++++++ .../resources_set_containers_test.go | 245 ++++++------------ .../resources_set_interactive_test.go | 132 ++-------- integration-tests/resources_set_test.go | 151 +++-------- integration-tests/resources_set_trial_test.go | 115 ++------ .../resources_set_values_test.go | 157 +++-------- .../resources_sizing_disabled_test.go | 6 +- pkg/mockapi/api_server.go | 4 + pkg/mockapi/environments.go | 66 +++++ pkg/mockapi/model.go | 30 ++- pkg/mockapi/projects.go | 11 + 17 files changed, 461 insertions(+), 785 deletions(-) create mode 100644 integration-tests/resources_helpers_test.go diff --git a/integration-tests/autoscaling_enabled_test.go b/integration-tests/autoscaling_enabled_test.go index 1a858cbb0..b7d2a2396 100644 --- a/integration-tests/autoscaling_enabled_test.go +++ b/integration-tests/autoscaling_enabled_test.go @@ -89,10 +89,7 @@ func setUpAutoscaling(apiHandler *mockapi.Handler, services map[string]any) (pro DefaultBranch: "main", }}) - autoscalingPath := "/projects/" + projectID + "/environments/main/autoscaling" main := makeEnv(projectID, "main", "production", "active", nil) - main.Links["#autoscaling"] = mockapi.HALLink{HREF: autoscalingPath} - main.Links["#manage-autoscaling"] = mockapi.HALLink{HREF: autoscalingPath} apiHandler.SetEnvironments([]*mockapi.Environment{main}) apiHandler.Get("/projects/"+projectID+"/capabilities", func(w http.ResponseWriter, _ *http.Request) { @@ -124,16 +121,13 @@ func setUpAutoscaling(apiHandler *mockapi.Handler, services map[string]any) (pro "up": map[string]any{"threshold": 80, "duration": 60}, "down": map[string]any{"threshold": 20, "duration": 600}, } - apiHandler.Get(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "defaults": map[string]any{ - "triggers": map[string]any{"cpu": trigger, "memory": trigger}, - "scale_cooldown": map[string]any{"up": 300, "down": 300}, - "instances": map[string]any{"min": 1, "max": 10}, - }, - "services": services, - "_links": mockapi.MakeHALLinks("self=" + autoscalingPath), - }) + main.SetAutoscalingSettings(map[string]any{ + "defaults": map[string]any{ + "triggers": map[string]any{"cpu": trigger, "memory": trigger}, + "scale_cooldown": map[string]any{"up": 300, "down": 300}, + "instances": map[string]any{"min": 1, "max": 10}, + }, + "services": services, }) return projectID diff --git a/integration-tests/autoscaling_missing_defaults_test.go b/integration-tests/autoscaling_missing_defaults_test.go index 14b49b34a..8e63f1d65 100644 --- a/integration-tests/autoscaling_missing_defaults_test.go +++ b/integration-tests/autoscaling_missing_defaults_test.go @@ -35,8 +35,6 @@ func TestAutoscalingSettingsSetMissingDefaults(t *testing.T) { }}) main := makeEnv(projectID, "main", "production", "active", nil) - main.Links["#autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} - main.Links["#manage-autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} apiHandler.SetEnvironments([]*mockapi.Environment{main}) apiHandler.Get("/projects/"+projectID+"/capabilities", func(w http.ResponseWriter, _ *http.Request) { @@ -73,14 +71,8 @@ func TestAutoscalingSettingsSetMissingDefaults(t *testing.T) { // Autoscaling settings with no "defaults" key — the API payload is // otherwise valid. The unfixed CLI assumed $defaults was always present. - autoscalingPath := "/projects/" + projectID + "/environments/main/autoscaling" - apiHandler.Get(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "services": map[string]any{}, - "_links": mockapi.MakeHALLinks( - "self=" + autoscalingPath, - ), - }) + main.SetAutoscalingSettings(map[string]any{ + "services": map[string]any{}, }) apiServer := httptest.NewServer(apiHandler) diff --git a/integration-tests/autoscaling_new_service_test.go b/integration-tests/autoscaling_new_service_test.go index 34023bc82..d5e2766ff 100644 --- a/integration-tests/autoscaling_new_service_test.go +++ b/integration-tests/autoscaling_new_service_test.go @@ -35,8 +35,6 @@ func TestAutoscalingSettingsSetNewService(t *testing.T) { }}) main := makeEnv(projectID, "main", "production", "active", nil) - main.Links["#autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} - main.Links["#manage-autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} apiHandler.SetEnvironments([]*mockapi.Environment{main}) apiHandler.Get("/projects/"+projectID+"/capabilities", func(w http.ResponseWriter, _ *http.Request) { @@ -73,24 +71,18 @@ func TestAutoscalingSettingsSetNewService(t *testing.T) { // Autoscaling settings with "app" NOT in services — so $current will be // null when summarizeChangesPerService is invoked for it. - autoscalingPath := "/projects/" + projectID + "/environments/main/autoscaling" - apiHandler.Get(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "defaults": map[string]any{ - "triggers": map[string]any{ - "cpu": map[string]any{ - "up": map[string]any{"threshold": 80, "duration": 60}, - "down": map[string]any{"threshold": 20, "duration": 60}, - }, + main.SetAutoscalingSettings(map[string]any{ + "defaults": map[string]any{ + "triggers": map[string]any{ + "cpu": map[string]any{ + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 60}, }, - "scale_cooldown": map[string]any{"up": 300, "down": 300}, - "instances": map[string]any{"min": 1, "max": 10}, }, - "services": map[string]any{}, - "_links": mockapi.MakeHALLinks( - "self=" + autoscalingPath, - ), - }) + "scale_cooldown": map[string]any{"up": 300, "down": 300}, + "instances": map[string]any{"min": 1, "max": 10}, + }, + "services": map[string]any{}, }) apiServer := httptest.NewServer(apiHandler) diff --git a/integration-tests/autoscaling_settings_set_test.go b/integration-tests/autoscaling_settings_set_test.go index c343dbcb6..16c45be43 100644 --- a/integration-tests/autoscaling_settings_set_test.go +++ b/integration-tests/autoscaling_settings_set_test.go @@ -107,8 +107,6 @@ func setupAutoscalingSettingsSet(t *testing.T) (f *cmdFactory, projectID string) }}) main := makeEnv(projectID, "main", "production", "active", nil) - main.Links["#autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} - main.Links["#manage-autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} apiHandler.SetEnvironments([]*mockapi.Environment{main}) apiHandler.Get("/projects/"+projectID+"/capabilities", func(w http.ResponseWriter, _ *http.Request) { @@ -147,40 +145,35 @@ func setupAutoscalingSettingsSet(t *testing.T) (f *cmdFactory, projectID string) // "down" sub-array, which is what triggers the formatDurationChange(null) // call in summarizeChangesPerService. autoscalingPath := "/projects/" + projectID + "/environments/main/autoscaling" - apiHandler.Get(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "defaults": map[string]any{ - "triggers": map[string]any{ - "cpu": map[string]any{ - "up": map[string]any{"threshold": 80, "duration": 60}, - "down": map[string]any{"threshold": 20, "duration": 60}, - }, - "memory": map[string]any{ - "up": map[string]any{"threshold": 80, "duration": 60}, - "down": map[string]any{"threshold": 20, "duration": 60}, - }, + main.SetAutoscalingSettings(map[string]any{ + "defaults": map[string]any{ + "triggers": map[string]any{ + "cpu": map[string]any{ + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 60}, + }, + "memory": map[string]any{ + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 60}, }, - "scale_cooldown": map[string]any{"up": 300, "down": 300}, - "instances": map[string]any{"min": 1, "max": 10}, }, - "services": map[string]any{ - "app": map[string]any{ - "enabled": true, - "triggers": map[string]any{ - "cpu": map[string]any{ - "enabled": true, - "up": map[string]any{"threshold": 80, "duration": 60}, - // No "down" key — this is what PHPStan flags. - }, + "scale_cooldown": map[string]any{"up": 300, "down": 300}, + "instances": map[string]any{"min": 1, "max": 10}, + }, + "services": map[string]any{ + "app": map[string]any{ + "enabled": true, + "triggers": map[string]any{ + "cpu": map[string]any{ + "enabled": true, + "up": map[string]any{"threshold": 80, "duration": 60}, + // No "down" key — this is what PHPStan flags. }, - "instances": map[string]any{"min": 1, "max": 3}, - // Also omit scale_cooldown to hit the cooldown variant. }, + "instances": map[string]any{"min": 1, "max": 3}, + // Also omit scale_cooldown to hit the cooldown variant. }, - "_links": mockapi.MakeHALLinks( - "self=" + autoscalingPath, - ), - }) + }, }) apiHandler.Patch(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { diff --git a/integration-tests/autoscaling_validate_metric_test.go b/integration-tests/autoscaling_validate_metric_test.go index 3a3b431e9..61d5a77e6 100644 --- a/integration-tests/autoscaling_validate_metric_test.go +++ b/integration-tests/autoscaling_validate_metric_test.go @@ -35,8 +35,6 @@ func TestAutoscalingSettingsSetMissingMetric(t *testing.T) { }}) main := makeEnv(projectID, "main", "production", "active", nil) - main.Links["#autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} - main.Links["#manage-autoscaling"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/autoscaling"} apiHandler.SetEnvironments([]*mockapi.Environment{main}) apiHandler.Get("/projects/"+projectID+"/capabilities", func(w http.ResponseWriter, _ *http.Request) { @@ -71,36 +69,30 @@ func TestAutoscalingSettingsSetMissingMetric(t *testing.T) { }) }) - autoscalingPath := "/projects/" + projectID + "/environments/main/autoscaling" - apiHandler.Get(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "defaults": map[string]any{ - "triggers": map[string]any{ - "cpu": map[string]any{ - "up": map[string]any{"threshold": 80, "duration": 60}, - "down": map[string]any{"threshold": 20, "duration": 60}, - }, + main.SetAutoscalingSettings(map[string]any{ + "defaults": map[string]any{ + "triggers": map[string]any{ + "cpu": map[string]any{ + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 60}, }, - "scale_cooldown": map[string]any{"up": 300, "down": 300}, - "instances": map[string]any{"min": 1, "max": 10}, }, - "services": map[string]any{ - "app": map[string]any{ - "enabled": true, - "triggers": map[string]any{ - "cpu": map[string]any{ - "enabled": true, - "up": map[string]any{"threshold": 80, "duration": 60}, - "down": map[string]any{"threshold": 20, "duration": 60}, - }, + "scale_cooldown": map[string]any{"up": 300, "down": 300}, + "instances": map[string]any{"min": 1, "max": 10}, + }, + "services": map[string]any{ + "app": map[string]any{ + "enabled": true, + "triggers": map[string]any{ + "cpu": map[string]any{ + "enabled": true, + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 60}, }, - "instances": map[string]any{"min": 1, "max": 3}, }, + "instances": map[string]any{"min": 1, "max": 3}, }, - "_links": mockapi.MakeHALLinks( - "self=" + autoscalingPath, - ), - }) + }, }) apiServer := httptest.NewServer(apiHandler) diff --git a/integration-tests/resources_get_test.go b/integration-tests/resources_get_test.go index 575ff585f..07f1df0bd 100644 --- a/integration-tests/resources_get_test.go +++ b/integration-tests/resources_get_test.go @@ -1,9 +1,6 @@ package tests import ( - "encoding/json" - "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -18,66 +15,22 @@ import ( // default ([]) was being cast to string and treated as a worker name in // Selector::selectRemoteContainer. func TestResourcesGet(t *testing.T) { - authServer := mockapi.NewAuthServer(t) - defer authServer.Close() - apiHandler := mockapi.NewHandler(t) - - projectID := mockapi.ProjectID() - - apiHandler.SetProjects([]*mockapi.Project{{ - ID: projectID, - 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, - }) - }) - - nextPath := "/projects/" + projectID + "/environments/main/deployments/next" - apiHandler.Get(nextPath, 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.1", - }, - "instance_count": 1, - "disk": 512, - }, - }, - "services": map[string]any{}, - "workers": map[string]any{}, - "routes": map[string]any{}, - "container_profiles": map[string]any{ - "BALANCED": map[string]any{ - "0.1": map[string]any{ - "cpu": "0.1", - "memory": "256", - "cpu_type": "guaranteed", - }, - }, - }, - }) - }) - - apiServer := httptest.NewServer(apiHandler) - defer apiServer.Close() - - f := newCommandFactory(t, apiServer.URL, authServer.URL) + projectID, _ := setUpResourcesProject(apiHandler, "org-id-1", nextDeployment(map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "BALANCED", + "resources": map[string]any{"profile_size": "0.1"}, + "instance_count": 1, + "disk": 512, + }, + }, map[string]any{ + "BALANCED": map[string]any{ + "0.1": map[string]any{"cpu": "0.1", "memory": "256", "cpu_type": "guaranteed"}, + }, + })) + f := serveAPI(t, apiHandler) stdout, stderr, err := f.RunCombinedOutput("resources:get", "-p", projectID, "-e", "main") require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) diff --git a/integration-tests/resources_helpers_test.go b/integration-tests/resources_helpers_test.go new file mode 100644 index 000000000..8230277a1 --- /dev/null +++ b/integration-tests/resources_helpers_test.go @@ -0,0 +1,91 @@ +package tests + +import ( + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +// setUpResourcesProject configures a user, an org, a project with the sizing +// API enabled, and its "main" environment with the given next deployment. +func setUpResourcesProject( + apiHandler *mockapi.Handler, orgID string, next map[string]any, +) (projectID string, main *mockapi.Environment) { + 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", + Settings: map[string]any{"sizing_api_enabled": true}, + }}) + + main = makeEnv(projectID, "main", "production", "active", nil) + main.SetNextDeployment(next) + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + return projectID, main +} + +// nextDeployment returns a next deployment with the given apps and container +// profiles, and no other containers. +func nextDeployment(webapps, containerProfiles map[string]any) map[string]any { + return map[string]any{ + "webapps": webapps, + "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": containerProfiles, + } +} + +// serveAPI starts servers for the API handler and for auth, and returns a +// command factory that uses them. +func serveAPI(t *testing.T, apiHandler *mockapi.Handler) *cmdFactory { + authServer := mockapi.NewAuthServer(t) + t.Cleanup(authServer.Close) + apiServer := httptest.NewServer(apiHandler) + t.Cleanup(apiServer.Close) + return newCommandFactory(t, apiServer.URL, authServer.URL) +} + +// deploymentPatch returns the body of the one update to the "main" +// environment's next deployment. +func deploymentPatch(t *testing.T, apiHandler *mockapi.Handler, projectID string) map[string]any { + patches := apiHandler.DeploymentPatches(projectID, "main") + require.Len(t, patches, 1, "expected one deployment update") + return patches[0] +} + +// patchedApp returns webapps.app from the deployment update. +func patchedApp(t *testing.T, apiHandler *mockapi.Handler, projectID string) map[string]any { + body := deploymentPatch(t, apiHandler, projectID) + webapps, ok := body["webapps"].(map[string]any) + require.True(t, ok, "PATCH body missing webapps: %v", body) + app, ok := webapps["app"].(map[string]any) + require.True(t, ok, "PATCH body missing webapps.app: %v", body) + return app +} diff --git a/integration-tests/resources_set_containers_test.go b/integration-tests/resources_set_containers_test.go index bc85f5818..13c0fb6cd 100644 --- a/integration-tests/resources_set_containers_test.go +++ b/integration-tests/resources_set_containers_test.go @@ -2,11 +2,7 @@ package tests import ( "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "sync/atomic" + "maps" "testing" "github.com/stretchr/testify/assert" @@ -15,167 +11,67 @@ import ( "github.com/upsun/cli/pkg/mockapi" ) -// setupResourcesSetContainers serves a project whose next deployment has an -// app, two workers, three services and a task. The "app--mail" worker has +// setUpResourcesSetContainers configures a project whose next deployment has +// an app, two workers, three services and a task. The "app--mail" worker has // autoscaling enabled, and "replica" supports horizontal scaling but has no -// instance_count. It returns the command factory, the project ID and a -// holder for the deployment PATCH body. -func setupResourcesSetContainers(t *testing.T) (f *cmdFactory, projectID string, patchBody *atomic.Value) { - authServer := mockapi.NewAuthServer(t) - t.Cleanup(authServer.Close) - - myUserID := "my-user-id" - apiHandler := mockapi.NewHandler(t) - apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) - - orgID := "org-id-1" - apiHandler.SetOrgs([]*mockapi.Org{{ - ID: orgID, - Type: "flexible", - Name: "acme", - Label: "Acme", - Owner: myUserID, - Capabilities: []string{}, - Links: mockapi.MakeHALLinks( - "self=/organizations/"+url.PathEscape(orgID), - "profile=/organizations/"+url.PathEscape(orgID)+"/profile", - ), - }}) - - projectID = mockapi.ProjectID() - apiHandler.SetProjects([]*mockapi.Project{{ - ID: projectID, - Organization: orgID, - Links: mockapi.MakeHALLinks( - "self=/projects/"+projectID, - "environments=/projects/"+projectID+"/environments", - ), - DefaultBranch: "main", - }}) - - envPath := "/projects/" + projectID + "/environments/main" - autoscalingPath := envPath + "/autoscaling" - main := makeEnv(projectID, "main", "production", "active", nil) - main.Links["#autoscaling"] = mockapi.HALLink{HREF: autoscalingPath} - apiHandler.SetEnvironments([]*mockapi.Environment{main}) - - apiHandler.Get("/projects/"+projectID+"/settings", func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{"sizing_api_enabled": true}) - }) - apiHandler.Get("/organizations/"+orgID+"/profile", func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{}) - }) - apiHandler.Get(autoscalingPath, func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "services": map[string]any{ - "app": map[string]any{"enabled": false}, - "app--mail": map[string]any{"enabled": true}, - }, - "_links": mockapi.MakeHALLinks("self=" + autoscalingPath), - }) - }) - +// instance_count. +func setUpResourcesSetContainers(apiHandler *mockapi.Handler) (projectID string) { disk := map[string]any{"minimum": map[string]any{"disk": 256}, "default": map[string]any{"disk": 512}} - withSize := func(size string, extra map[string]any) map[string]any { + resources := func(size string, extra map[string]any) map[string]any { r := map[string]any{"profile_size": size} - for k, v := range extra { - r[k] = v - } + maps.Copy(r, extra) return r } - nextPath := envPath + "/deployments/next" - apiHandler.Get(nextPath, 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": withSize("0.5", disk), - "instance_count": 1, - "disk": 512, - }, - }, - "workers": map[string]any{ - "app--queue": map[string]any{ - "name": "app--queue", - "type": "golang:1.23", - "container_profile": "BALANCED", - "resources": withSize("0.5", nil), - "instance_count": 1, - }, - "app--mail": map[string]any{ - "name": "app--mail", - "type": "golang:1.23", - "container_profile": "BALANCED", - "resources": withSize("0.5", nil), - "instance_count": 2, - }, - }, - "services": map[string]any{ - "db": map[string]any{ - "type": "mariadb:11.4", - "container_profile": "BALANCED", - "resources": withSize("1", disk), - "instance_count": 1, - "disk": 1024, - "supports_horizontal_scaling": false, - }, - // No instance_count, which means 1. - "replica": map[string]any{ - "type": "mariadb-replica:11.4", - "container_profile": "BALANCED", - "resources": withSize("0.5", disk), - "disk": 1024, - "supports_horizontal_scaling": true, - }, - // No minimum disk, so no disk can be set. - "cache": map[string]any{ - "type": "redis:7.2", - "container_profile": "BALANCED", - "resources": withSize("0.5", nil), - "instance_count": 1, - }, - }, - "tasks": map[string]any{ - "cleanup": map[string]any{ - "name": "cleanup", - "type": "golang:1.23", - "resources": withSize("0.5", nil), - }, - }, - "routes": map[string]any{}, - "project_info": map[string]any{ - "settings": map[string]any{}, - "capabilities": map[string]any{"instance_limit": 4}, - }, - "container_profiles": map[string]any{ - "BALANCED": map[string]any{ - "0.5": map[string]any{"cpu": 0.5, "memory": 1024, "cpu_type": "shared"}, - "1": map[string]any{"cpu": 1, "memory": 2048, "cpu_type": "shared"}, - "2": map[string]any{"cpu": 2, "memory": 4096, "cpu_type": "shared"}, - }, - }, - "_links": mockapi.MakeHALLinks("self="+nextPath, "#edit="+nextPath), - }) - }) + container := func(typ, size string, extra map[string]any) map[string]any { + c := map[string]any{"type": typ, "container_profile": "BALANCED", "resources": resources(size, nil)} + maps.Copy(c, extra) + return c + } - patchBody = &atomic.Value{} - apiHandler.Patch(nextPath, func(w http.ResponseWriter, r *http.Request) { - b, err := io.ReadAll(r.Body) - require.NoError(t, err) - var body map[string]any - require.NoError(t, json.Unmarshal(b, &body)) - patchBody.Store(body) - _ = json.NewEncoder(w).Encode(map[string]any{ - "_embedded": map[string]any{"activities": []any{}}, - }) + next := nextDeployment(map[string]any{ + "app": container("golang:1.23", "0.5", map[string]any{ + "resources": resources("0.5", disk), "instance_count": 1, "disk": 512, + }), + }, map[string]any{ + "BALANCED": map[string]any{ + "0.5": map[string]any{"cpu": 0.5, "memory": 1024, "cpu_type": "shared"}, + "1": map[string]any{"cpu": 1, "memory": 2048, "cpu_type": "shared"}, + "2": map[string]any{"cpu": 2, "memory": 4096, "cpu_type": "shared"}, + }, }) + next["workers"] = map[string]any{ + "app--queue": container("golang:1.23", "0.5", map[string]any{"instance_count": 1}), + "app--mail": container("golang:1.23", "0.5", map[string]any{"instance_count": 2}), + } + next["services"] = map[string]any{ + "db": container("mariadb:11.4", "1", map[string]any{ + "resources": resources("1", disk), "instance_count": 1, "disk": 1024, + "supports_horizontal_scaling": false, + }), + // No instance_count, which means 1. + "replica": container("mariadb-replica:11.4", "0.5", map[string]any{ + "resources": resources("0.5", disk), "disk": 1024, + "supports_horizontal_scaling": true, + }), + // No minimum disk, so no disk can be set. + "cache": container("redis:7.2", "0.5", map[string]any{"instance_count": 1}), + } + next["tasks"] = map[string]any{ + "cleanup": map[string]any{"name": "cleanup", "type": "golang:1.23", "resources": resources("0.5", nil)}, + } + next["project_info"] = map[string]any{ + "settings": map[string]any{}, + "capabilities": map[string]any{"instance_limit": 4}, + } - apiServer := httptest.NewServer(apiHandler) - t.Cleanup(apiServer.Close) - - return newCommandFactory(t, apiServer.URL, authServer.URL), projectID, patchBody + projectID, main := setUpResourcesProject(apiHandler, "org-id-1", next) + main.SetAutoscalingSettings(map[string]any{ + "services": map[string]any{ + "app": map[string]any{"enabled": false}, + "app--mail": map[string]any{"enabled": true}, + }, + }) + return projectID } // TestResourcesSet_Containers checks resources:set options across apps, @@ -317,7 +213,9 @@ func TestResourcesSet_Containers(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - f, projectID, patchBody := setupResourcesSetContainers(t) + apiHandler := mockapi.NewHandler(t) + projectID := setUpResourcesSetContainers(apiHandler) + f := serveAPI(t, apiHandler) args := append([]string{"resources:set", "-p", projectID, "-e", "main", "--no-wait", "--yes"}, c.args...) stdout, stderr, err := f.RunCombinedOutput(args...) @@ -331,14 +229,10 @@ func TestResourcesSet_Containers(t *testing.T) { } if c.wantPatch == nil { - assert.Nil(t, patchBody.Load(), "no deployment update should be sent") + assert.Empty(t, apiHandler.DeploymentPatches(projectID, "main"), "no deployment update should be sent") return } - want, err := json.Marshal(c.wantPatch) - require.NoError(t, err) - got, err := json.Marshal(patchBody.Load()) - require.NoError(t, err) - assert.JSONEq(t, string(want), string(got)) + assertJSONEq(t, c.wantPatch, deploymentPatch(t, apiHandler, projectID)) }) } } @@ -363,7 +257,9 @@ func TestResourcesSet_ContainersInteractive(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - f, projectID, patchBody := setupResourcesSetContainers(t) + apiHandler := mockapi.NewHandler(t) + projectID := setUpResourcesSetContainers(apiHandler) + f := serveAPI(t, apiHandler) stdout, stderr, err := f.RunInteractive( c.input, @@ -379,14 +275,19 @@ func TestResourcesSet_ContainersInteractive(t *testing.T) { if c.wantPatch == nil { assert.Contains(t, stderr, "nothing to update") - assert.Nil(t, patchBody.Load(), "no deployment update should be sent") + assert.Empty(t, apiHandler.DeploymentPatches(projectID, "main"), "no deployment update should be sent") return } - want, err := json.Marshal(c.wantPatch) - require.NoError(t, err) - got, err := json.Marshal(patchBody.Load()) - require.NoError(t, err) - assert.JSONEq(t, string(want), string(got)) + assertJSONEq(t, c.wantPatch, deploymentPatch(t, apiHandler, projectID)) }) } } + +// assertJSONEq asserts that two values are equal once encoded as JSON. +func assertJSONEq(t *testing.T, want, got any) { + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + gotJSON, err := json.Marshal(got) + require.NoError(t, err) + assert.JSONEq(t, string(wantJSON), string(gotJSON)) +} diff --git a/integration-tests/resources_set_interactive_test.go b/integration-tests/resources_set_interactive_test.go index cdfa8f557..08ce00fbc 100644 --- a/integration-tests/resources_set_interactive_test.go +++ b/integration-tests/resources_set_interactive_test.go @@ -1,12 +1,6 @@ package tests import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -20,104 +14,27 @@ import ( // resulting deployment update. Accepting every default must change nothing; // entering new values must submit them in the deployment PATCH body. func TestResourcesSet_Interactive(t *testing.T) { - authServer := mockapi.NewAuthServer(t) - defer authServer.Close() - - myUserID := "my-user-id" apiHandler := mockapi.NewHandler(t) - apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) - - orgID := "org-id-1" - apiHandler.SetOrgs([]*mockapi.Org{{ - ID: orgID, - Type: "flexible", - Name: "acme", - Label: "Acme", - Owner: myUserID, - Capabilities: []string{}, - Links: mockapi.MakeHALLinks( - "self=/organizations/"+url.PathEscape(orgID), - "profile=/organizations/"+url.PathEscape(orgID)+"/profile", - ), - }}) - - 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, - }) - }) - - // No resources_limit, so the trial-limit branch is skipped. - apiHandler.Get("/organizations/"+orgID+"/profile", func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{}) - }) - - nextPath := "/projects/" + projectID + "/environments/main/deployments/next" - apiHandler.Get(nextPath, 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": "HIGH_CPU", - "resources": map[string]any{ - "profile_size": "0.5", - "minimum": map[string]any{"disk": 512}, - "default": map[string]any{"disk": 512}, - }, - "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{}, + projectID, _ := setUpResourcesProject(apiHandler, "org-id-1", nextDeployment(map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "HIGH_CPU", + "resources": map[string]any{ + "profile_size": "0.5", + "minimum": map[string]any{"disk": 512}, + "default": map[string]any{"disk": 512}, }, - "container_profiles": map[string]any{ - "HIGH_CPU": map[string]any{ - "0.5": map[string]any{"cpu": "0.5", "memory": "224", "cpu_type": "shared"}, - "1": map[string]any{"cpu": "1", "memory": "384", "cpu_type": "shared"}, - }, - }, - "_links": mockapi.MakeHALLinks("self="+nextPath, "#edit="+nextPath), - }) - }) - - var patchBody atomic.Value // map[string]any - apiHandler.Patch(nextPath, func(w http.ResponseWriter, r *http.Request) { - b, err := io.ReadAll(r.Body) - require.NoError(t, err) - var body map[string]any - require.NoError(t, json.Unmarshal(b, &body)) - patchBody.Store(body) - _ = json.NewEncoder(w).Encode(map[string]any{ - "_embedded": map[string]any{"activities": []any{}}, - }) - }) - - apiServer := httptest.NewServer(apiHandler) - defer apiServer.Close() - - f := newCommandFactory(t, apiServer.URL, authServer.URL) + "instance_count": 1, + "disk": 512, + }, + }, map[string]any{ + "HIGH_CPU": map[string]any{ + "0.5": map[string]any{"cpu": "0.5", "memory": "224", "cpu_type": "shared"}, + "1": map[string]any{"cpu": "1", "memory": "384", "cpu_type": "shared"}, + }, + })) + f := serveAPI(t, apiHandler) t.Run("accepting defaults changes nothing", func(t *testing.T) { // Newlines accept the profile size, instance count and disk defaults. @@ -134,7 +51,7 @@ func TestResourcesSet_Interactive(t *testing.T) { assert.Contains(t, stderr, "Enter the number of instances") assert.Contains(t, combined, "nothing to update") - assert.Nil(t, patchBody.Load(), "no deployment update should be submitted") + assert.Empty(t, apiHandler.DeploymentPatches(projectID, "main"), "no deployment update should be submitted") }) t.Run("entering new values submits them", func(t *testing.T) { @@ -153,14 +70,9 @@ func TestResourcesSet_Interactive(t *testing.T) { assert.Contains(t, stderr, "Summary of changes") assert.Contains(t, stderr, "Setting the resources") - body, ok := patchBody.Load().(map[string]any) - require.True(t, ok, "deployment PATCH was not received") - webapps, ok := body["webapps"].(map[string]any) - require.True(t, ok, "PATCH body missing webapps: %v", body) - app, ok := webapps["app"].(map[string]any) - require.True(t, ok, "PATCH body missing webapps.app: %v", body) + app := patchedApp(t, apiHandler, projectID) resources, ok := app["resources"].(map[string]any) - require.True(t, ok, "PATCH body missing webapps.app.resources: %v", body) + require.True(t, ok, "PATCH body missing webapps.app.resources: %v", app) assert.Equal(t, "1", resources["profile_size"]) assert.EqualValues(t, 2, app["instance_count"]) diff --git a/integration-tests/resources_set_test.go b/integration-tests/resources_set_test.go index 5032fd8a8..9d9fc36b2 100644 --- a/integration-tests/resources_set_test.go +++ b/integration-tests/resources_set_test.go @@ -1,10 +1,6 @@ package tests import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" "testing" "github.com/stretchr/testify/assert" @@ -26,93 +22,30 @@ import ( // --size app:0.1 change is then requested with --dry-run, forcing the // command to print the previous-vs-new summary before exiting. func TestResourcesSet_CurrentSizeMissingFromContainerProfiles(t *testing.T) { - authServer := mockapi.NewAuthServer(t) - defer authServer.Close() - - myUserID := "my-user-id" apiHandler := mockapi.NewHandler(t) - apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) - - orgID := "org-id-1" - apiHandler.SetOrgs([]*mockapi.Org{{ - ID: orgID, - Type: "flexible", - Name: "acme", - Label: "Acme", - Owner: myUserID, - Capabilities: []string{}, - Links: mockapi.MakeHALLinks( - "self=/organizations/"+url.PathEscape(orgID), - "profile=/organizations/"+url.PathEscape(orgID)+"/profile", - ), - }}) - - 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, - }) - }) - // 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{ - "webapps": map[string]any{ - "app": map[string]any{ - "name": "app", - "type": "golang:1.23", - "container_profile": "BALANCED", - "resources": map[string]any{ - // Current size "0.5" is intentionally NOT - // present in container_profiles["BALANCED"] - // below, so sizeInfo() returns null. - "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": "256", - "cpu_type": "guaranteed", - }, - }, + projectID, _ := setUpResourcesProject(apiHandler, "org-id-1", nextDeployment(map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "BALANCED", + "resources": map[string]any{ + // Current size "0.5" is intentionally NOT present in + // container_profiles["BALANCED"] below, so sizeInfo() + // returns null. + "profile_size": "0.5", }, - }) - }) - - apiServer := httptest.NewServer(apiHandler) - defer apiServer.Close() - - f := newCommandFactory(t, apiServer.URL, authServer.URL) + "instance_count": 1, + "disk": 512, + }, + }, map[string]any{ + "BALANCED": map[string]any{ + "0.1": map[string]any{"cpu": "0.1", "memory": "256", "cpu_type": "guaranteed"}, + }, + })) + f := serveAPI(t, apiHandler) stdout, stderr, err := f.RunCombinedOutput( "resources:set", @@ -137,36 +70,24 @@ func TestResourcesSet_CurrentSizeMissingFromContainerProfiles(t *testing.T) { // is shown in the error for a profile size below it. func TestResourcesSet_SizeBelowMinimumCPU(t *testing.T) { apiHandler := mockapi.NewHandler(t) - projectID := setUpResourcesSetOrg(apiHandler, "org-min-cpu") - // Replace the deployment with one whose app has a minimum CPU. - nextPath := "/projects/" + projectID + "/environments/main/deployments/next" - apiHandler.Get(nextPath, 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", - "minimum": map[string]any{"cpu": 0.25, "memory": 64}, - }, - "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"}, - }, + projectID, _ := setUpResourcesProject(apiHandler, "org-min-cpu", nextDeployment(map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "BALANCED", + "resources": map[string]any{ + "profile_size": "0.5", + "minimum": map[string]any{"cpu": 0.25, "memory": 64}, }, - }) - }) + "instance_count": 1, + "disk": 512, + }, + }, 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"}, + }, + })) _, stderr, err := runResourcesSet(t, apiHandler, projectID, "app:0.1") diff --git a/integration-tests/resources_set_trial_test.go b/integration-tests/resources_set_trial_test.go index 884e5755d..50c160cd5 100644 --- a/integration-tests/resources_set_trial_test.go +++ b/integration-tests/resources_set_trial_test.go @@ -3,8 +3,6 @@ package tests import ( "encoding/json" "net/http" - "net/http/httptest" - "net/url" "testing" "github.com/stretchr/testify/assert" @@ -21,91 +19,24 @@ func setUpResourcesSetOrg(apiHandler *mockapi.Handler, orgID string) (projectID // setUpResourcesSetOrgWithSize is setUpResourcesSetOrg with the app's current profile size. func setUpResourcesSetOrgWithSize(apiHandler *mockapi.Handler, orgID, currentSize 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": currentSize, - }, - "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", - }, - // Filtered out: the project does not support guaranteed CPU. - "2": map[string]any{ - "cpu": "2", - "memory": "512", - "cpu_type": "guaranteed", - }, - }, - }, - }) - }) - + projectID, _ = setUpResourcesProject(apiHandler, orgID, nextDeployment(map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "BALANCED", + "resources": map[string]any{"profile_size": currentSize}, + "instance_count": 1, + "disk": 512, + }, + }, 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"}, + // Filtered out: the project does not support guaranteed CPU. + "2": map[string]any{"cpu": "2", "memory": "512", "cpu_type": "guaranteed"}, + }, + })) return projectID } @@ -167,15 +98,7 @@ func runResourcesSet( func runResourcesSetArgs( t *testing.T, apiHandler *mockapi.Handler, projectID string, args ...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(append([]string{ + return serveAPI(t, apiHandler).RunCombinedOutput(append([]string{ "resources:set", "-p", projectID, "-e", "main", diff --git a/integration-tests/resources_set_values_test.go b/integration-tests/resources_set_values_test.go index 0e17c8688..97642d21b 100644 --- a/integration-tests/resources_set_values_test.go +++ b/integration-tests/resources_set_values_test.go @@ -1,12 +1,6 @@ package tests import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -15,99 +9,6 @@ import ( "github.com/upsun/cli/pkg/mockapi" ) -// setupResourcesSetApp serves a project whose next deployment has a single app -// with the given properties and container profiles. It returns the command -// factory, the project ID and a holder for the deployment PATCH body. -func setupResourcesSetApp( - t *testing.T, app, containerProfiles map[string]any, -) (f *cmdFactory, projectID string, patchBody *atomic.Value) { - authServer := mockapi.NewAuthServer(t) - t.Cleanup(authServer.Close) - - myUserID := "my-user-id" - apiHandler := mockapi.NewHandler(t) - apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) - - orgID := "org-id-1" - apiHandler.SetOrgs([]*mockapi.Org{{ - ID: orgID, - Type: "flexible", - Name: "acme", - Label: "Acme", - Owner: myUserID, - Capabilities: []string{}, - Links: mockapi.MakeHALLinks( - "self=/organizations/"+url.PathEscape(orgID), - "profile=/organizations/"+url.PathEscape(orgID)+"/profile", - ), - }}) - - 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}) - }) - apiHandler.Get("/organizations/"+orgID+"/profile", func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{}) - }) - - nextPath := "/projects/" + projectID + "/environments/main/deployments/next" - apiHandler.Get(nextPath, func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "webapps": map[string]any{"app": app}, - "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": containerProfiles, - "_links": mockapi.MakeHALLinks("self="+nextPath, "#edit="+nextPath), - }) - }) - - patchBody = &atomic.Value{} - apiHandler.Patch(nextPath, func(w http.ResponseWriter, r *http.Request) { - b, err := io.ReadAll(r.Body) - require.NoError(t, err) - var body map[string]any - require.NoError(t, json.Unmarshal(b, &body)) - patchBody.Store(body) - _ = json.NewEncoder(w).Encode(map[string]any{ - "_embedded": map[string]any{"activities": []any{}}, - }) - }) - - apiServer := httptest.NewServer(apiHandler) - t.Cleanup(apiServer.Close) - - return newCommandFactory(t, apiServer.URL, authServer.URL), projectID, patchBody -} - -// patchedApp returns webapps.app from a deployment PATCH body. -func patchedApp(t *testing.T, patchBody *atomic.Value) map[string]any { - body, ok := patchBody.Load().(map[string]any) - require.True(t, ok, "deployment PATCH was not received") - webapps, ok := body["webapps"].(map[string]any) - require.True(t, ok, "PATCH body missing webapps: %v", body) - app, ok := webapps["app"].(map[string]any) - require.True(t, ok, "PATCH body missing webapps.app: %v", body) - return app -} - // TestResourcesSet_DiskKeywords checks the documented 'default' and 'min' // values for --disk. func TestResourcesSet_DiskKeywords(t *testing.T) { @@ -121,29 +22,33 @@ func TestResourcesSet_DiskKeywords(t *testing.T) { } for _, c := range cases { t.Run(c.value, func(t *testing.T) { - f, projectID, patchBody := setupResourcesSetApp(t, map[string]any{ - "name": "app", - "type": "golang:1.23", - "container_profile": "HIGH_CPU", - "resources": map[string]any{ - "profile_size": "1", - "minimum": map[string]any{"disk": 256}, - "default": map[string]any{"disk": 2048}, + apiHandler := mockapi.NewHandler(t) + projectID, _ := setUpResourcesProject(apiHandler, "org-id-1", nextDeployment(map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "HIGH_CPU", + "resources": map[string]any{ + "profile_size": "1", + "minimum": map[string]any{"disk": 256}, + "default": map[string]any{"disk": 2048}, + }, + "instance_count": 1, + "disk": 512, }, - "instance_count": 1, - "disk": 512, }, map[string]any{ "HIGH_CPU": map[string]any{ "1": map[string]any{"cpu": "1", "memory": "384", "cpu_type": "shared"}, }, - }) + })) + f := serveAPI(t, apiHandler) stdout, stderr, err := f.RunCombinedOutput( "resources:set", "-p", projectID, "-e", "main", "--no-wait", "--yes", "--disk", "app:"+c.value, ) require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) - assert.EqualValues(t, c.want, patchedApp(t, patchBody)["disk"]) + assert.EqualValues(t, c.want, patchedApp(t, apiHandler, projectID)["disk"]) }) } } @@ -151,25 +56,29 @@ func TestResourcesSet_DiskKeywords(t *testing.T) { // TestResourcesSet_InteractiveIntegerProfileSizes checks that choosing a // profile size returns the size, when every size offered is an integer. func TestResourcesSet_InteractiveIntegerProfileSizes(t *testing.T) { - f, projectID, patchBody := setupResourcesSetApp(t, map[string]any{ - "name": "app", - "type": "golang:1.23", - "container_profile": "HIGH_CPU", - "resources": map[string]any{ - "profile_size": "1", - // The minimum CPU hides the 0.5 size, leaving only integer sizes. - "minimum": map[string]any{"cpu": 1, "disk": 512}, - "default": map[string]any{"disk": 512}, + apiHandler := mockapi.NewHandler(t) + projectID, _ := setUpResourcesProject(apiHandler, "org-id-1", nextDeployment(map[string]any{ + "app": map[string]any{ + "name": "app", + "type": "golang:1.23", + "container_profile": "HIGH_CPU", + "resources": map[string]any{ + "profile_size": "1", + // The minimum CPU hides the 0.5 size, leaving only integer sizes. + "minimum": map[string]any{"cpu": 1, "disk": 512}, + "default": map[string]any{"disk": 512}, + }, + "instance_count": 1, + "disk": 512, }, - "instance_count": 1, - "disk": 512, }, map[string]any{ "HIGH_CPU": map[string]any{ "0.5": map[string]any{"cpu": "0.5", "memory": "224", "cpu_type": "shared"}, "1": map[string]any{"cpu": "1", "memory": "384", "cpu_type": "shared"}, "2": map[string]any{"cpu": "2", "memory": "768", "cpu_type": "shared"}, }, - }) + })) + f := serveAPI(t, apiHandler) // Choose profile size "2", accept the instance count and disk, confirm. stdout, stderr, err := f.RunInteractive( @@ -178,7 +87,7 @@ func TestResourcesSet_InteractiveIntegerProfileSizes(t *testing.T) { ) require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) - resources, ok := patchedApp(t, patchBody)["resources"].(map[string]any) + resources, ok := patchedApp(t, apiHandler, projectID)["resources"].(map[string]any) require.True(t, ok, "PATCH body missing webapps.app.resources") assert.Equal(t, "2", resources["profile_size"]) } diff --git a/integration-tests/resources_sizing_disabled_test.go b/integration-tests/resources_sizing_disabled_test.go index 61d955e7d..b228d04ea 100644 --- a/integration-tests/resources_sizing_disabled_test.go +++ b/integration-tests/resources_sizing_disabled_test.go @@ -1,8 +1,6 @@ package tests import ( - "encoding/json" - "net/http" "net/http/httptest" "net/url" "testing" @@ -69,13 +67,11 @@ func TestResourcesSizingDisabled(t *testing.T) { "environments=/projects/"+projectID+"/environments", ), DefaultBranch: "main", + Settings: map[string]any{"sizing_api_enabled": false}, }}) 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": false}) - }) apiServer := httptest.NewServer(apiHandler) defer apiServer.Close() diff --git a/pkg/mockapi/api_server.go b/pkg/mockapi/api_server.go index fb6d896fe..623f2a9d0 100644 --- a/pkg/mockapi/api_server.go +++ b/pkg/mockapi/api_server.go @@ -68,6 +68,7 @@ func NewHandler(t *testing.T) *Handler { h.Get("/projects/{project_id}", h.handleGetProject) h.Patch("/projects/{project_id}", h.handlePatchProject) + h.Get("/projects/{project_id}/settings", h.handleGetProjectSettings) h.Get("/projects/{project_id}/environments", h.handleListEnvironments) h.Get("/projects/{project_id}/environments/{environment_id}", h.handleGetEnvironment) h.Patch("/projects/{project_id}/environments/{environment_id}", h.handlePatchEnvironment) @@ -81,6 +82,9 @@ func NewHandler(t *testing.T) *Handler { h.Get("/projects/{project_id}/environments/{environment_id}/backups", h.handleListBackups) h.Post("/projects/{project_id}/environments/{environment_id}/backups", h.handleCreateBackup) h.Get("/projects/{project_id}/environments/{environment_id}/deployments/current", h.handleGetCurrentDeployment) + h.Get("/projects/{project_id}/environments/{environment_id}/deployments/next", h.handleGetNextDeployment) + h.Patch("/projects/{project_id}/environments/{environment_id}/deployments/next", h.handlePatchNextDeployment) + h.Get("/projects/{project_id}/environments/{environment_id}/autoscaling", h.handleGetAutoscalingSettings) h.Get("/projects/{project_id}/domains", h.handleListProjectDomains) h.Get("/projects/{project_id}/domains/{name}", h.handleGetProjectDomain) h.Get("/projects/{project_id}/integrations", h.handleListProjectIntegrations) diff --git a/pkg/mockapi/environments.go b/pkg/mockapi/environments.go index a898f730d..342820c4e 100644 --- a/pkg/mockapi/environments.go +++ b/pkg/mockapi/environments.go @@ -4,6 +4,7 @@ import ( "cmp" "crypto/rand" "encoding/json" + "maps" "net/http" "slices" "time" @@ -150,6 +151,71 @@ func (h *Handler) handleGetCurrentDeployment(w http.ResponseWriter, req *http.Re _ = json.NewEncoder(w).Encode(d) } +func (h *Handler) handleGetNextDeployment(w http.ResponseWriter, req *http.Request) { + env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) + h.RLock() + defer h.RUnlock() + if env == nil || env.nextDeployment == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(withSelfLinks(env.nextDeployment, req.URL.Path, true)) +} + +func (h *Handler) handlePatchNextDeployment(w http.ResponseWriter, req *http.Request) { + env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) + if env == nil { + w.WriteHeader(http.StatusNotFound) + return + } + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + h.Lock() + env.deploymentPatches = append(env.deploymentPatches, body) + h.Unlock() + _ = json.NewEncoder(w).Encode(activityResponse()) +} + +// DeploymentPatches returns the bodies of PATCH requests to an environment's next deployment. +func (h *Handler) DeploymentPatches(projectID, environmentID string) []map[string]any { + env := h.findEnvironment(projectID, environmentID) + h.RLock() + defer h.RUnlock() + if env == nil { + return nil + } + return slices.Clone(env.deploymentPatches) +} + +func (h *Handler) handleGetAutoscalingSettings(w http.ResponseWriter, req *http.Request) { + env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) + h.RLock() + defer h.RUnlock() + if env == nil || env.autoscalingSettings == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(withSelfLinks(env.autoscalingSettings, req.URL.Path, false)) +} + +// withSelfLinks returns a copy of data with a "self" link, and optionally an +// "#edit" link, to the path, unless it already has links. +func withSelfLinks(data map[string]any, path string, edit bool) map[string]any { + if _, ok := data["_links"]; ok { + return data + } + links := MakeHALLinks("self=" + path) + if edit { + links["#edit"] = HALLink{HREF: path} + } + result := maps.Clone(data) + result["_links"] = links + return result +} + func (h *Handler) handleCreateBackup(w http.ResponseWriter, req *http.Request) { projectID := chi.URLParam(req, "project_id") environmentID := chi.URLParam(req, "environment_id") diff --git a/pkg/mockapi/model.go b/pkg/mockapi/model.go index bb900cb2f..fd44681dc 100644 --- a/pkg/mockapi/model.go +++ b/pkg/mockapi/model.go @@ -3,6 +3,7 @@ package mockapi // TODO unify these models with the 'api' package, and/or use OpenAPI or similar to generate them import ( + "net/url" "strings" "time" ) @@ -64,6 +65,9 @@ type Project struct { Subscription ProjectSubscriptionInfo `json:"subscription,omitempty"` SubscriptionID string `json:"-"` + + // Settings are served at the project's /settings path, if set. + Settings map[string]any `json:"-"` } type ProjectSubscriptionInfo struct { @@ -108,14 +112,36 @@ type Environment struct { UpdatedAt time.Time `json:"updated_at"` Links HalLinks `json:"_links"` - currentDeployment *Deployment - settings map[string]any + currentDeployment *Deployment + nextDeployment map[string]any + deploymentPatches []map[string]any + autoscalingSettings map[string]any + settings map[string]any } func (e *Environment) SetCurrentDeployment(d *Deployment) { e.currentDeployment = d } +// SetNextDeployment sets the next deployment, which is served as raw data so +// that tests can include fields the Deployment type lacks. PATCH requests to +// it are recorded, and returned by Handler.DeploymentPatches. +func (e *Environment) SetNextDeployment(d map[string]any) { + e.nextDeployment = d +} + +// SetAutoscalingSettings sets the autoscaling settings, and the environment +// links needed to read and manage them. +func (e *Environment) SetAutoscalingSettings(s map[string]any) { + e.autoscalingSettings = s + if e.Links == nil { + e.Links = make(HalLinks) + } + path := "/projects/" + url.PathEscape(e.Project) + "/environments/" + url.PathEscape(e.ID) + "/autoscaling" + e.Links["#autoscaling"] = HALLink{HREF: path} + e.Links["#manage-autoscaling"] = HALLink{HREF: path} +} + func (e *Environment) SetSetting(key string, val any) { if e.settings == nil { e.settings = make(map[string]any) diff --git a/pkg/mockapi/projects.go b/pkg/mockapi/projects.go index 83bd160de..55e301f20 100644 --- a/pkg/mockapi/projects.go +++ b/pkg/mockapi/projects.go @@ -37,6 +37,17 @@ func (h *Handler) handleGetProject(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusNotFound) } +func (h *Handler) handleGetProjectSettings(w http.ResponseWriter, req *http.Request) { + h.RLock() + defer h.RUnlock() + p, ok := h.projects[chi.URLParam(req, "project_id")] + if !ok || p.Settings == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(p.Settings) +} + func (h *Handler) handlePatchProject(w http.ResponseWriter, req *http.Request) { h.Lock() defer h.Unlock()