Skip to content

Commit c3b6e49

Browse files
committed
Add --app-scope option for variables
1 parent 3dc1b2a commit c3b6e49

File tree

4 files changed

+171
-27
lines changed

4 files changed

+171
-27
lines changed

go-tests/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25
44

55
require (
66
github.com/go-chi/chi/v5 v5.2.3
7-
github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba
7+
github.com/platformsh/cli v0.0.0-20260128142111-698419ab4b95
88
github.com/stretchr/testify v1.11.1
99
golang.org/x/crypto v0.45.0
1010
)

go-tests/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hH
55
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
66
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
77
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
8-
github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba h1:3WGCXeQ5sGhK+0LBQLb1SGDTBdkqrQUoJJ4yBk8aytY=
9-
github.com/platformsh/cli v0.0.0-20250919110327-7c630d2efeba/go.mod h1:BIRcjGc1Hikr4axLI3BEjEon4Iuo1AKIkN1L5ILQGmE=
8+
github.com/platformsh/cli v0.0.0-20260128142111-698419ab4b95 h1:8dNJJFWRammRQGOTZ/qT+pObpqkCxs+6aGNngpixJNs=
9+
github.com/platformsh/cli v0.0.0-20260128142111-698419ab4b95/go.mod h1:2V6SWpEDjZsO51upyha6DHVifnSLT5UAaUWaX7jnN6Y=
1010
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
1111
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1212
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

go-tests/variable_write_test.go

Lines changed: 116 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,61 +8,153 @@ import (
88
"github.com/stretchr/testify/assert"
99
)
1010

11-
func TestVariableCreate(t *testing.T) {
11+
// variableTestSetup holds common test infrastructure for variable tests.
12+
type variableTestSetup struct {
13+
authServer *httptest.Server
14+
apiServer *httptest.Server
15+
apiHandler *mockapi.Handler
16+
projectID string
17+
mainEnv *mockapi.Environment
18+
factory *cmdFactory
19+
}
20+
21+
// setupVariableTest creates the common test infrastructure for variable tests.
22+
func setupVariableTest(t *testing.T) *variableTestSetup {
1223
authServer := mockapi.NewAuthServer(t)
13-
defer authServer.Close()
24+
t.Cleanup(authServer.Close)
1425

1526
apiHandler := mockapi.NewHandler(t)
1627
apiServer := httptest.NewServer(apiHandler)
17-
defer apiServer.Close()
28+
t.Cleanup(apiServer.Close)
1829

1930
projectID := mockapi.ProjectID()
2031

2132
apiHandler.SetProjects([]*mockapi.Project{{
2233
ID: projectID,
23-
Links: mockapi.MakeHALLinks("self=/projects/"+projectID,
24-
"environments=/projects/"+projectID+"/environments"),
34+
Links: mockapi.MakeHALLinks(
35+
"self=/projects/"+projectID,
36+
"environments=/projects/"+projectID+"/environments",
37+
),
2538
DefaultBranch: "main",
2639
}})
27-
main := makeEnv(projectID, "main", "production", "active", nil)
28-
main.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
29-
main.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
30-
envs := []*mockapi.Environment{main}
31-
apiHandler.SetEnvironments(envs)
3240

33-
apiHandler.SetProjectVariables(projectID, []*mockapi.Variable{
41+
mainEnv := makeEnv(projectID, "main", "production", "active", nil)
42+
mainEnv.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
43+
mainEnv.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"}
44+
45+
return &variableTestSetup{
46+
authServer: authServer,
47+
apiServer: apiServer,
48+
apiHandler: apiHandler,
49+
projectID: projectID,
50+
mainEnv: mainEnv,
51+
factory: newCommandFactory(t, apiServer.URL, authServer.URL),
52+
}
53+
}
54+
55+
func TestVariableCreate(t *testing.T) {
56+
s := setupVariableTest(t)
57+
s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})
58+
s.apiHandler.SetProjectVariables(s.projectID, []*mockapi.Variable{
3459
{
3560
Name: "existing",
3661
IsSensitive: true,
3762
VisibleBuild: true,
3863
},
3964
})
4065

41-
f := newCommandFactory(t, apiServer.URL, authServer.URL)
66+
f, p := s.factory, s.projectID
4267

43-
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", projectID, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value")
68+
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "e", "-e", "main", "env:TEST", "--value", "env-level-value")
4469
assert.NoError(t, err)
4570
assert.Contains(t, stdErr, "Creating variable env:TEST on the environment main")
4671

47-
assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value"))
72+
assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value"))
4873

49-
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value")
74+
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value")
5075
assert.NoError(t, err)
51-
assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+projectID)
76+
assert.Contains(t, stdErr, "Creating variable env:TEST on the project "+p)
5277

53-
assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "p"))
54-
assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", projectID, "-e", "main", "env:TEST", "-P", "value", "-l", "e"))
78+
assertTrimmed(t, "project-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "p"))
79+
assertTrimmed(t, "env-level-value", f.Run("var:get", "-p", p, "-e", "main", "env:TEST", "-P", "value", "-l", "e"))
5580

56-
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", projectID, "existing", "-l", "p", "--value", "test")
81+
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "existing", "-l", "p", "--value", "test")
5782
assert.Error(t, err)
5883
assert.Contains(t, stdErr, "The variable already exists")
5984

60-
_, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--value", "project-level-value2")
85+
_, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--value", "project-level-value2")
86+
assert.NoError(t, err)
87+
assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "value"))
88+
89+
assertTrimmed(t, "true", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime"))
90+
_, _, err = f.RunCombinedOutput("var:update", "-p", p, "env:TEST", "-l", "p", "--visible-runtime", "false")
91+
assert.NoError(t, err)
92+
assertTrimmed(t, "false", f.Run("var:get", "-p", p, "env:TEST", "-l", "p", "-P", "visible_runtime"))
93+
}
94+
95+
func TestVariableCreateWithAppScope(t *testing.T) {
96+
s := setupVariableTest(t)
97+
98+
// Set up deployment with app names for validation.
99+
s.mainEnv.SetCurrentDeployment(&mockapi.Deployment{
100+
WebApps: map[string]mockapi.App{
101+
"app1": {Name: "app1", Type: "golang:1.23"},
102+
"app2": {Name: "app2", Type: "php:8.3"},
103+
},
104+
Routes: make(map[string]any),
105+
Links: mockapi.MakeHALLinks("self=/projects/" + s.projectID + "/environments/main/deployment/current"),
106+
})
107+
s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})
108+
109+
f, p := s.factory, s.projectID
110+
111+
// Test creating project-level variable with single app-scope.
112+
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
113+
"env:SCOPED", "--value", "val", "--app-scope", "app1")
114+
assert.NoError(t, err)
115+
assert.Contains(t, stdErr, "Creating variable env:SCOPED")
116+
117+
// Verify application_scope was set.
118+
out := f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope")
119+
assert.Contains(t, out, "app1")
120+
121+
// Test creating variable with multiple app scopes.
122+
_, _, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
123+
"env:MULTI", "--value", "val", "--app-scope", "app1", "--app-scope", "app2")
61124
assert.NoError(t, err)
62-
assertTrimmed(t, "project-level-value2", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "value"))
63125

64-
assertTrimmed(t, "true", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime"))
65-
_, _, err = f.RunCombinedOutput("var:update", "-p", projectID, "env:TEST", "-l", "p", "--visible-runtime", "false")
126+
out = f.Run("var:get", "-p", p, "-l", "p", "env:MULTI", "-P", "application_scope")
127+
assert.Contains(t, out, "app1")
128+
assert.Contains(t, out, "app2")
129+
130+
// Test validation rejects invalid app names (when deployment exists).
131+
_, stdErr, err = f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
132+
"env:BAD", "--value", "val", "--app-scope", "nonexistent")
133+
assert.Error(t, err)
134+
assert.Contains(t, stdErr, "was not found")
135+
136+
// Test updating app-scope.
137+
_, _, err = f.RunCombinedOutput("var:update", "-p", p, "-l", "p",
138+
"env:SCOPED", "--app-scope", "app2")
66139
assert.NoError(t, err)
67-
assertTrimmed(t, "false", f.Run("var:get", "-p", projectID, "env:TEST", "-l", "p", "-P", "visible_runtime"))
140+
141+
out = f.Run("var:get", "-p", p, "-l", "p", "env:SCOPED", "-P", "application_scope")
142+
assert.Contains(t, out, "app2")
143+
}
144+
145+
func TestVariableCreateWithAppScopeNoDeployment(t *testing.T) {
146+
// Uses an environment without a deployment, so app-scope validation is skipped.
147+
s := setupVariableTest(t)
148+
s.apiHandler.SetEnvironments([]*mockapi.Environment{s.mainEnv})
149+
150+
f, p := s.factory, s.projectID
151+
152+
// Without a deployment, any app-scope value should be accepted.
153+
_, stdErr, err := f.RunCombinedOutput("var:create", "-p", p, "-l", "p",
154+
"env:ANY_APP", "--value", "val", "--app-scope", "anyapp")
155+
assert.NoError(t, err)
156+
assert.Contains(t, stdErr, "Creating variable env:ANY_APP")
157+
158+
out := f.Run("var:get", "-p", p, "-l", "p", "env:ANY_APP", "-P", "application_scope")
159+
assert.Contains(t, out, "anyapp")
68160
}

src/Command/Variable/VariableCommandBase.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
use Platformsh\Cli\Command\CommandBase;
66
use Platformsh\Cli\Console\AdaptiveTableCell;
7+
use Platformsh\Client\Model\Environment;
8+
use Platformsh\Client\Model\Project;
79
use Platformsh\Client\Model\ProjectLevelVariable;
810
use Platformsh\Client\Model\Resource as ApiResource;
911
use Platformsh\Client\Model\Variable as EnvironmentLevelVariable;
12+
use Platformsh\ConsoleForm\Field\ArrayField;
1013
use Platformsh\ConsoleForm\Field\BooleanField;
1114
use Platformsh\ConsoleForm\Field\Field;
1215
use Platformsh\ConsoleForm\Field\OptionsField;
@@ -182,6 +185,31 @@ protected function getFields()
182185
return null;
183186
},
184187
]);
188+
$fields['application_scope'] = new ArrayField('Application scope', [
189+
'optionName' => 'app-scope',
190+
'description' => 'A list of application names to which this variable will apply.',
191+
'questionLine' => 'To which applications should this variable apply?',
192+
'default' => [],
193+
'required' => false,
194+
'avoidQuestion' => true,
195+
'validator' => function ($values) {
196+
$appNames = $this->listApps($this->getSelectedProject(), $this->hasSelectedEnvironment() ? $this->getSelectedEnvironment() : null);
197+
if ($appNames === false) {
198+
// No app names available: skip validation.
199+
return true;
200+
}
201+
foreach ($values as $value) {
202+
if (!in_array($value, $appNames, true)) {
203+
throw new InvalidArgumentException(sprintf(
204+
'The app "%s" was not found. Valid app names are: %s',
205+
$value,
206+
implode(', ', $appNames)
207+
));
208+
}
209+
}
210+
return true;
211+
},
212+
]);
185213
$fields['name'] = new Field('Name', [
186214
'description' => 'The variable name',
187215
'validators' => [
@@ -277,4 +305,28 @@ private function getPrefixOptions($name)
277305
'env:' => 'env: The variable will be exposed directly, e.g. as <comment>$' . strtoupper($name) . '</comment>.',
278306
];
279307
}
308+
309+
/**
310+
* List application names for validating application_scope values.
311+
*
312+
* @param Project $project
313+
* @param Environment|null $environment If not provided, the project's default environment will be used.
314+
*
315+
* @return string[]|false
316+
*/
317+
private function listApps(Project $project, Environment $environment = null) {
318+
if (!$environment) {
319+
if ($project->default_branch !== '') {
320+
$environment = $this->api()->getEnvironment($project->default_branch, $project);
321+
}
322+
if (!$environment) {
323+
return false;
324+
}
325+
}
326+
$deployment = $this->api()->getCurrentDeployment($environment, false, false);
327+
if (!$deployment) {
328+
return false;
329+
}
330+
return array_keys($deployment->webapps);
331+
}
280332
}

0 commit comments

Comments
 (0)