Skip to content

Commit 4427ce3

Browse files
committed
Add endpoint to download a deployment.
1 parent 2e73057 commit 4427ce3

File tree

3 files changed

+302
-0
lines changed

3 files changed

+302
-0
lines changed

apiserver/controllers/deployments/deployments.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"strconv"
99
"strings"
10+
"time"
1011

1112
log "github.com/Sirupsen/logrus"
1213
"github.com/gin-gonic/gin"
@@ -31,6 +32,8 @@ const (
3132
viaTemplate
3233
)
3334

35+
const presignExpiryDuration = 1 * time.Minute
36+
3437
// Create deploys a project.
3538
func Create(c *gin.Context) {
3639
u := controllers.CurrentUser(c)
@@ -384,6 +387,83 @@ func Show(c *gin.Context) {
384387
})
385388
}
386389

390+
// Download allows users to download an (unoptimized) tarball of the files of a
391+
// deployment.
392+
func Download(c *gin.Context) {
393+
deploymentID, err := strconv.ParseInt(c.Param("id"), 10, 64)
394+
if err != nil {
395+
c.JSON(http.StatusNotFound, gin.H{
396+
"error": "not_found",
397+
"error_description": "deployment could not be found",
398+
})
399+
return
400+
}
401+
402+
db, err := dbconn.DB()
403+
if err != nil {
404+
controllers.InternalServerError(c, err)
405+
return
406+
}
407+
408+
depl := &deployment.Deployment{}
409+
if err := db.First(depl, deploymentID).Error; err != nil {
410+
if err == gorm.RecordNotFound {
411+
c.JSON(http.StatusNotFound, gin.H{
412+
"error": "not_found",
413+
"error_description": "deployment could not be found",
414+
})
415+
return
416+
}
417+
controllers.InternalServerError(c, err)
418+
return
419+
}
420+
421+
if depl.RawBundleID == nil {
422+
c.JSON(http.StatusNotFound, gin.H{
423+
"error": "not_found",
424+
"error_description": "deployment cannot be downloaded",
425+
})
426+
return
427+
}
428+
429+
bun := &rawbundle.RawBundle{}
430+
if err := db.First(bun, *depl.RawBundleID).Error; err != nil {
431+
if err == gorm.RecordNotFound {
432+
c.JSON(http.StatusGone, gin.H{
433+
"error": "gone",
434+
"error_description": "deployment can no longer be downloaded",
435+
})
436+
return
437+
}
438+
controllers.InternalServerError(c, err)
439+
return
440+
}
441+
442+
exists, err := s3client.Exists(bun.UploadedPath)
443+
if err != nil {
444+
log.Warnf("failed to check existence of %q on S3, err: %v", bun.UploadedPath, err)
445+
controllers.InternalServerError(c, err)
446+
return
447+
}
448+
if !exists {
449+
log.Warnf("deployment raw bundle %q does not exist in S3", bun.UploadedPath)
450+
c.JSON(http.StatusGone, gin.H{
451+
"error": "gone",
452+
"error_description": "deployment can no longer be downloaded",
453+
})
454+
return
455+
}
456+
457+
url, err := s3client.PresignedURL(bun.UploadedPath, presignExpiryDuration)
458+
if err != nil {
459+
log.Printf("error generating presigned URL to %q, err: %v", bun.UploadedPath, err)
460+
controllers.InternalServerError(c, err)
461+
return
462+
}
463+
464+
c.Redirect(http.StatusFound, url)
465+
}
466+
387467
// Rollback either rolls back a project to the previous deployment, or to a
388468
// given version.
389469
func Rollback(c *gin.Context) {

apiserver/controllers/deployments/deployments_test.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package deployments_test
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"io/ioutil"
@@ -939,6 +940,226 @@ var _ = Describe("Deployments", func() {
939940
})
940941
})
941942

943+
Describe("GET /projects/:project_name/deployments/:id/download", func() {
944+
var (
945+
err error
946+
947+
fakeS3 *fake.S3
948+
origS3 filetransfer.FileTransfer
949+
950+
u *user.User
951+
t *oauthtoken.OauthToken
952+
953+
headers http.Header
954+
proj *project.Project
955+
depl *deployment.Deployment
956+
bun *rawbundle.RawBundle
957+
)
958+
959+
BeforeEach(func() {
960+
origS3 = s3client.S3
961+
fakeS3 = &fake.S3{}
962+
s3client.S3 = fakeS3
963+
964+
u, _, t = factories.AuthTrio(db)
965+
966+
headers = http.Header{
967+
"Authorization": {"Bearer " + t.Token},
968+
}
969+
970+
proj = &project.Project{
971+
Name: "foo-bar-express",
972+
UserID: u.ID,
973+
}
974+
Expect(db.Create(proj).Error).To(BeNil())
975+
976+
bun = factories.RawBundle(db, proj)
977+
978+
depl = factories.DeploymentWithAttrs(db, proj, u, deployment.Deployment{
979+
Prefix: "a1b2c3",
980+
State: deployment.StateDeployed,
981+
DeployedAt: timeAgo(-1 * time.Hour),
982+
RawBundleID: &bun.ID,
983+
})
984+
})
985+
986+
AfterEach(func() {
987+
s3client.S3 = origS3
988+
})
989+
990+
doRequest := func() {
991+
s = httptest.NewServer(server.New())
992+
uri := fmt.Sprintf("%s/projects/foo-bar-express/deployments/%d/download", s.URL, depl.ID)
993+
994+
req, err := http.NewRequest("GET", uri, nil)
995+
Expect(err).To(BeNil())
996+
997+
if headers != nil {
998+
for k, v := range headers {
999+
for _, h := range v {
1000+
req.Header.Add(k, h)
1001+
}
1002+
}
1003+
}
1004+
1005+
// Hack so that we don't follow redirects.
1006+
// In Go 1.7, we can use http.ErrUseLastResponse (see https://github.com/golang/go/commit/8f13080267d0ddbb50da9029339796841224116a).
1007+
redirectErr := errors.New("redirect received")
1008+
client := &http.Client{
1009+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
1010+
if len(via) > 0 {
1011+
return redirectErr
1012+
}
1013+
return nil
1014+
},
1015+
}
1016+
1017+
res, err = client.Do(req)
1018+
if err != nil {
1019+
if e, ok := err.(*url.Error); ok {
1020+
Expect(e.Err).To(Equal(redirectErr))
1021+
return
1022+
}
1023+
}
1024+
1025+
Expect(err).To(BeNil())
1026+
}
1027+
1028+
sharedexamples.ItRequiresAuthentication(func() (*gorm.DB, *user.User, *http.Header) {
1029+
return db, u, &headers
1030+
}, func() *http.Response {
1031+
doRequest()
1032+
return res
1033+
}, nil)
1034+
1035+
sharedexamples.ItRequiresProjectCollab(func() (*gorm.DB, *user.User, *project.Project) {
1036+
return db, u, proj
1037+
}, func() *http.Response {
1038+
doRequest()
1039+
return res
1040+
}, nil)
1041+
1042+
Context("when the deployment does not exist", func() {
1043+
BeforeEach(func() {
1044+
Expect(db.Delete(depl).Error).To(BeNil())
1045+
})
1046+
1047+
It("responds with 404 Not Found", func() {
1048+
doRequest()
1049+
1050+
b := &bytes.Buffer{}
1051+
_, err = b.ReadFrom(res.Body)
1052+
1053+
Expect(res.StatusCode).To(Equal(http.StatusNotFound))
1054+
Expect(b.String()).To(MatchJSON(`{
1055+
"error": "not_found",
1056+
"error_description": "deployment could not be found"
1057+
}`))
1058+
})
1059+
})
1060+
1061+
Context("when the deployment id is not a number", func() {
1062+
BeforeEach(func() {
1063+
s = httptest.NewServer(server.New())
1064+
url := fmt.Sprintf("%s/projects/foo-bar-express/deployments/cafebabe", s.URL)
1065+
res, err = testhelper.MakeRequest("GET", url, nil, headers, nil)
1066+
Expect(err).To(BeNil())
1067+
})
1068+
1069+
It("responds with 404 Not Found", func() {
1070+
b := &bytes.Buffer{}
1071+
_, err = b.ReadFrom(res.Body)
1072+
1073+
Expect(res.StatusCode).To(Equal(http.StatusNotFound))
1074+
Expect(b.String()).To(MatchJSON(`{
1075+
"error": "not_found",
1076+
"error_description": "deployment could not be found"
1077+
}`))
1078+
})
1079+
})
1080+
1081+
Context("when the deployment does not have an associated RawBundle", func() {
1082+
BeforeEach(func() {
1083+
depl.RawBundleID = nil
1084+
Expect(db.Save(depl).Error).To(BeNil())
1085+
})
1086+
1087+
It("responds with 404 Not Found", func() {
1088+
doRequest()
1089+
1090+
b := &bytes.Buffer{}
1091+
_, err = b.ReadFrom(res.Body)
1092+
1093+
Expect(res.StatusCode).To(Equal(http.StatusNotFound))
1094+
Expect(b.String()).To(MatchJSON(`{
1095+
"error": "not_found",
1096+
"error_description": "deployment cannot be downloaded"
1097+
}`))
1098+
})
1099+
})
1100+
1101+
Context("when the deployment's associated RawBundle does not exist", func() {
1102+
BeforeEach(func() {
1103+
Expect(db.Delete(bun).Error).To(BeNil())
1104+
})
1105+
1106+
It("responds with 410 Gone", func() {
1107+
doRequest()
1108+
1109+
b := &bytes.Buffer{}
1110+
_, err = b.ReadFrom(res.Body)
1111+
1112+
Expect(res.StatusCode).To(Equal(http.StatusGone))
1113+
Expect(b.String()).To(MatchJSON(`{
1114+
"error": "gone",
1115+
"error_description": "deployment can no longer be downloaded"
1116+
}`))
1117+
})
1118+
})
1119+
1120+
Context("when raw bundle does not exist on S3", func() {
1121+
BeforeEach(func() {
1122+
fakeS3.ExistsReturn = false
1123+
})
1124+
1125+
It("responds with 410 Gone", func() {
1126+
doRequest()
1127+
1128+
b := &bytes.Buffer{}
1129+
_, err = b.ReadFrom(res.Body)
1130+
1131+
Expect(res.StatusCode).To(Equal(http.StatusGone))
1132+
Expect(b.String()).To(MatchJSON(`{
1133+
"error": "gone",
1134+
"error_description": "deployment can no longer be downloaded"
1135+
}`))
1136+
1137+
Expect(fakeS3.ExistsCalls.Count()).To(Equal(1))
1138+
})
1139+
})
1140+
1141+
Context("when raw bundle exists on S3", func() {
1142+
BeforeEach(func() {
1143+
fakeS3.ExistsReturn = true
1144+
fakeS3.PresignedURLReturn = "https://s3-us-west-2.amazonaws.com/deployments/abcd/raw-bundle.zip?abc=123"
1145+
})
1146+
1147+
It("redirects to a pre-signed download URL of the raw bundle in S3", func() {
1148+
doRequest()
1149+
1150+
Expect(fakeS3.PresignedURLCalls.Count()).To(Equal(1))
1151+
call := fakeS3.PresignedURLCalls.NthCall(1)
1152+
Expect(call).NotTo(BeNil())
1153+
Expect(call.Arguments[0]).To(Equal(s3client.BucketRegion))
1154+
Expect(call.Arguments[1]).To(Equal(s3client.BucketName))
1155+
Expect(call.Arguments[2]).To(Equal(bun.UploadedPath))
1156+
1157+
Expect(res.StatusCode).To(Equal(http.StatusFound))
1158+
Expect(res.Header.Get("Location")).To(Equal(fakeS3.PresignedURLReturn))
1159+
})
1160+
})
1161+
})
1162+
9421163
Describe("POST /projects/:project_name/rollback", func() {
9431164
var (
9441165
err error

apiserver/routes/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ func Draw(r *gin.Engine) {
5555
projCollab := authorized.Group("/projects/:project_name", middleware.RequireProjectCollab)
5656

5757
projCollab.GET("", projects.Get)
58+
projCollab.GET("/deployments/:id/download", deployments.Download)
5859
projCollab.GET("/deployments/:id", deployments.Show)
5960
projCollab.GET("/deployments", deployments.Index)
6061
projCollab.GET("repos", repos.Show)

0 commit comments

Comments
 (0)