Skip to content

Commit 86c12d9

Browse files
Joshua AndersonJoshua-Anderson
authored andcommitted
feat(controller): allow users to transfer app ownership.
1 parent 1330404 commit 86c12d9

17 files changed

Lines changed: 1693 additions & 9 deletions

File tree

client/cmd/apps.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,24 @@ func AppDestroy(appID, confirm string) error {
240240

241241
return nil
242242
}
243+
244+
// AppTransfer transfers app ownership to another user.
245+
func AppTransfer(appID, username string) error {
246+
c, appID, err := load(appID)
247+
248+
if err != nil {
249+
return err
250+
}
251+
252+
fmt.Printf("Transferring %s to %s... ", appID, username)
253+
254+
err = apps.Transfer(c, appID, username)
255+
256+
if err != nil {
257+
return err
258+
}
259+
260+
fmt.Println("done")
261+
262+
return nil
263+
}

client/controller/api/apps.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ type AppCreateRequest struct {
1515
ID string `json:"id,omitempty"`
1616
}
1717

18+
// AppUpdateRequest is the definition of POST /v1/apps/<app id>/.
19+
type AppUpdateRequest struct {
20+
Owner string `json:"owner,omitempty"`
21+
}
22+
1823
// AppRunRequest is the definition of POST /v1/apps/<app id>/run.
1924
type AppRunRequest struct {
2025
Command string `json:"command"`

client/controller/models/apps/apps.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,18 @@ func Delete(c *client.Client, appID string) error {
123123
_, err := c.BasicRequest("DELETE", u, nil)
124124
return err
125125
}
126+
127+
// Transfer an app to another user.
128+
func Transfer(c *client.Client, appID string, username string) error {
129+
u := fmt.Sprintf("/v1/apps/%s/", appID)
130+
131+
req := api.AppUpdateRequest{Owner: username}
132+
body, err := json.Marshal(req)
133+
134+
if err != nil {
135+
return err
136+
}
137+
138+
_, err = c.BasicRequest("POST", u, body)
139+
return err
140+
}

client/controller/models/apps/apps_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const appsFixture string = `
4545

4646
const appCreateExpected string = `{"id":"example-go"}`
4747
const appRunExpected string = `{"command":"echo hi"}`
48+
const appTransferExpected string = `{"owner":"test"}`
4849

4950
type fakeHTTPServer struct {
5051
createID bool
@@ -127,6 +128,27 @@ func (f *fakeHTTPServer) ServeHTTP(res http.ResponseWriter, req *http.Request) {
127128
return
128129
}
129130

131+
if req.URL.Path == "/v1/apps/example-go/" && req.Method == "POST" {
132+
body, err := ioutil.ReadAll(req.Body)
133+
134+
if err != nil {
135+
fmt.Println(err)
136+
res.WriteHeader(http.StatusInternalServerError)
137+
res.Write(nil)
138+
}
139+
140+
if string(body) != appTransferExpected {
141+
fmt.Printf("Expected '%s', Got '%s'\n", appTransferExpected, body)
142+
res.WriteHeader(http.StatusInternalServerError)
143+
res.Write(nil)
144+
return
145+
}
146+
147+
res.WriteHeader(http.StatusNoContent)
148+
res.Write(nil)
149+
return
150+
}
151+
130152
fmt.Printf("Unrecongized URL %s\n", req.URL)
131153
res.WriteHeader(http.StatusNotFound)
132154
res.Write(nil)
@@ -347,3 +369,25 @@ func TestAppsLogs(t *testing.T) {
347369
}
348370
}
349371
}
372+
373+
func TestAppsTransfer(t *testing.T) {
374+
t.Parallel()
375+
376+
handler := fakeHTTPServer{}
377+
server := httptest.NewServer(&handler)
378+
defer server.Close()
379+
380+
u, err := url.Parse(server.URL)
381+
382+
if err != nil {
383+
t.Fatal(err)
384+
}
385+
386+
httpClient := client.CreateHTTPClient(false)
387+
388+
client := client.Client{HTTPClient: httpClient, ControllerURL: *u, Token: "abc"}
389+
390+
if err = Transfer(&client, "example-go", "test"); err != nil {
391+
t.Fatal(err)
392+
}
393+
}

client/parser/apps.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
docopt "github.com/docopt/docopt-go"
99
)
1010

11-
// Apps routes app commands to the specific function
11+
// Apps routes app commands to their specific function.
1212
func Apps(argv []string) error {
1313
usage := `
1414
Valid commands for apps:
@@ -20,6 +20,7 @@ apps:open open the application in a browser
2020
apps:logs view aggregated application logs
2121
apps:run run a command in an ephemeral app container
2222
apps:destroy destroy an application
23+
apps:transfer transfer app ownership to another user
2324
2425
Use 'deis help [command]' to learn more.
2526
`
@@ -39,6 +40,8 @@ Use 'deis help [command]' to learn more.
3940
return appRun(argv)
4041
case "apps:destroy":
4142
return appDestroy(argv)
43+
case "apps:transfer":
44+
return appTransfer(argv)
4245
default:
4346
if printHelp(argv, usage) {
4447
return nil
@@ -244,3 +247,26 @@ Options:
244247

245248
return cmd.AppDestroy(app, confirm)
246249
}
250+
251+
func appTransfer(argv []string) error {
252+
usage := `
253+
Transfer app ownership to another user.
254+
255+
Usage: deis apps:transfer <username> [options]
256+
257+
Arguments:
258+
<username>
259+
the user that the app will be transfered to.
260+
261+
Options:
262+
-a --app=<app>
263+
the uniquely identifiable name for the application.
264+
`
265+
args, err := docopt.Parse(usage, argv, true, "", false, true)
266+
267+
if err != nil {
268+
return err
269+
}
270+
271+
return cmd.AppTransfer(safeGetValue(args, "--app"), safeGetValue(args, "<username>"))
272+
}

controller/api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
The **api** Django app presents a RESTful web API for interacting with the **deis** system.
33
"""
44

5-
__version__ = '1.6.0'
5+
__version__ = '1.7.0'

controller/api/fixtures/tests.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,23 @@
3434
"email": "autotest2@deis.io",
3535
"date_joined": "2013-05-10T16:08:09.357Z"
3636
}
37+
},
38+
{
39+
"pk": 9,
40+
"model": "auth.user",
41+
"fields": {
42+
"username": "autotest3",
43+
"first_name": "Otto",
44+
"last_name": "Test",
45+
"is_active": true,
46+
"is_superuser": false,
47+
"is_staff": false,
48+
"last_login": "2013-05-10T16:08:09.357Z",
49+
"groups": [],
50+
"user_permissions": [],
51+
"password": "pbkdf2_sha256$10000$5Uoq7dl61vnN$gQhDpc2q2Rkn16VdPC+pNNEQcKpy+LGe29Zkad+2/m4=",
52+
"email": "autotest3@deis.io",
53+
"date_joined": "2013-05-10T16:08:09.357Z"
54+
}
3755
}
3856
]

controller/api/tests/test_app.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,52 @@ def test_app_info_not_showing_wrong_app(self):
324324
response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
325325
self.assertEqual(response.status_code, 404)
326326

327+
def test_app_transfer(self):
328+
owner = User.objects.get(username='autotest2')
329+
owner_token = Token.objects.get(user=owner).key
330+
app_id = 'autotest'
331+
base_url = '/v1/apps'
332+
body = {'id': app_id}
333+
response = self.client.post(base_url, json.dumps(body), content_type='application/json',
334+
HTTP_AUTHORIZATION='token {}'.format(owner_token))
335+
# Transfer App
336+
url = '{}/{}'.format(base_url, app_id)
337+
new_owner = User.objects.get(username='autotest3')
338+
new_owner_token = Token.objects.get(user=new_owner).key
339+
body = {'owner': new_owner.username}
340+
response = self.client.post(url, json.dumps(body), content_type='application/json',
341+
HTTP_AUTHORIZATION='token {}'.format(owner_token))
342+
self.assertEqual(response.status_code, 200)
343+
344+
# Original user can no longer access it
345+
response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(owner_token))
346+
self.assertEqual(response.status_code, 403)
347+
348+
# New owner can access it
349+
response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(new_owner_token))
350+
self.assertEqual(response.status_code, 200)
351+
self.assertEqual(response.data['owner'], new_owner.username)
352+
353+
# Collaborators can't transfer
354+
body = {'username': owner.username}
355+
perms_url = url+"/perms/"
356+
response = self.client.post(perms_url, json.dumps(body), content_type='application/json',
357+
HTTP_AUTHORIZATION='token {}'.format(new_owner_token))
358+
self.assertEqual(response.status_code, 201)
359+
body = {'owner': self.user.username}
360+
response = self.client.post(url, json.dumps(body), content_type='application/json',
361+
HTTP_AUTHORIZATION='token {}'.format(owner_token))
362+
self.assertEqual(response.status_code, 403)
363+
364+
# Admins can transfer
365+
body = {'owner': self.user.username}
366+
response = self.client.post(url, json.dumps(body), content_type='application/json',
367+
HTTP_AUTHORIZATION='token {}'.format(self.token))
368+
self.assertEqual(response.status_code, 200)
369+
response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
370+
self.assertEqual(response.status_code, 200)
371+
self.assertEqual(response.data['owner'], self.user.username)
372+
327373

328374
FAKE_LOG_DATA = """
329375
2013-08-15 12:41:25 [33454] [INFO] Starting gunicorn 17.5

controller/api/tests/test_users.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ def test_super_user_can_list(self):
2121
HTTP_AUTHORIZATION='token {}'.format(token))
2222

2323
self.assertEqual(response.status_code, 200)
24-
self.assertEqual(len(response.data['results']), 2)
25-
self.assertEqual(response.data['results'][0]['username'], 'autotest')
26-
self.assertEqual(response.data['results'][1]['username'], 'autotest2')
24+
self.assertEqual(len(response.data['results']), 3)
2725

2826
def test_non_super_user_cannot_list(self):
2927
url = '/v1/users/'

controller/api/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
views.AppPermsViewSet.as_view({'get': 'list', 'post': 'create'})),
6464
# apps base endpoint
6565
url(r"^apps/(?P<id>{})/?".format(settings.APP_URL_REGEX),
66-
views.AppViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),
66+
views.AppViewSet.as_view({'get': 'retrieve', 'post': 'update', 'delete': 'destroy'})),
6767
url(r'^apps/?',
6868
views.AppViewSet.as_view({'get': 'list', 'post': 'create'})),
6969
# key

0 commit comments

Comments
 (0)