github.com/fibonacci1729/draft@v0.3.0/api/server.go (about) 1 package api 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/sha1" 7 "encoding/base64" 8 "encoding/json" 9 "fmt" 10 "io" 11 "net" 12 "net/http" 13 "os" 14 "strconv" 15 "strings" 16 "syscall" 17 "time" 18 19 log "github.com/Sirupsen/logrus" 20 "github.com/docker/docker/api/types" 21 docker "github.com/docker/docker/client" 22 "github.com/docker/docker/pkg/jsonmessage" 23 "github.com/docker/docker/pkg/term" 24 "github.com/ghodss/yaml" 25 "github.com/gorilla/websocket" 26 "github.com/julienschmidt/httprouter" 27 "k8s.io/helm/pkg/chartutil" 28 "k8s.io/helm/pkg/helm" 29 "k8s.io/helm/pkg/proto/hapi/release" 30 "k8s.io/helm/pkg/storage/driver" 31 "k8s.io/helm/pkg/strvals" 32 33 "github.com/Azure/draft/pkg/version" 34 ) 35 36 // WebsocketUpgrader represents the default websocket.Upgrader that Draft employs 37 var WebsocketUpgrader = websocket.Upgrader{ 38 EnableCompression: true, 39 // reduce the WriteBufferSize so `docker build` and `docker push` responses aren't internally 40 // buffered by gorilla/websocket, but smaller, more informative messages can still be buffered. 41 // https://github.com/gorilla/websocket/blob/9bc973af0682dc73a22553a08bfe00ee6255f56f/conn.go#L586-L593 42 WriteBufferSize: 128, 43 } 44 45 // Server is an API Server which listens and responds to HTTP requests. 46 type Server struct { 47 HTTPServer *http.Server 48 Listener net.Listener 49 DockerClient *docker.Client 50 HelmClient *helm.Client 51 // RegistryAuth is the authorization token used to push images up to the registry. 52 // 53 // This field follows the format of the X-Registry-Auth header. 54 RegistryAuth string 55 // RegistryOrg is the organization (e.g. your DockerHub account) used to push images 56 // up to the registry. 57 RegistryOrg string 58 // RegistryURL is the URL of the registry (e.g. quay.io, docker.io, gcr.io) 59 RegistryURL string 60 // Basedomain is the basedomain used to construct the ingress rules 61 Basedomain string 62 } 63 64 // Serve starts the HTTP server, accepting all new connections. 65 func (s *Server) Serve() error { 66 return s.HTTPServer.Serve(s.Listener) 67 } 68 69 // Close shuts down the HTTP server, dropping all current connections. 70 func (s *Server) Close() error { 71 return s.Listener.Close() 72 } 73 74 // ServeRequest processes a single HTTP request. 75 func (s *Server) ServeRequest(w http.ResponseWriter, req *http.Request) { 76 s.HTTPServer.Handler.ServeHTTP(w, req) 77 } 78 79 func (s *Server) createRouter() { 80 r := httprouter.New() 81 82 routerMap := map[string]map[string]httprouter.Handle{ 83 "GET": { 84 "/ping": ping, 85 "/version": getVersion, 86 }, 87 "POST": { 88 "/apps/:id": buildApp, 89 }, 90 } 91 92 for method, routes := range routerMap { 93 for route, funct := range routes { 94 // disable logging on /ping requests 95 if route != "/ping" { 96 funct = logRequestMiddleware(funct) 97 } 98 r.Handle(method, route, s.Middleware(funct)) 99 } 100 } 101 s.HTTPServer.Handler = r 102 } 103 104 type contextKey string 105 106 func (c contextKey) String() string { 107 return "api context key " + string(c) 108 } 109 110 // Middleware adds additional context before handling requests 111 func (s *Server) Middleware(h httprouter.Handle) httprouter.Handle { 112 return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 113 // attach the API server to the request params so that it can retrieve info about itself 114 ctx := context.WithValue(r.Context(), contextKey("server"), s) 115 // Delegate request to the given handle 116 h(w, r.WithContext(ctx), p) 117 } 118 } 119 120 // NewServer sets up the required Server and does protocol specific checking. 121 func NewServer(proto, addr string) (*Server, error) { 122 var ( 123 a *Server 124 err error 125 ) 126 switch proto { 127 case "tcp": 128 a, err = setupTCPHTTP(addr) 129 case "unix": 130 a, err = setupUnixHTTP(addr) 131 default: 132 a, err = nil, fmt.Errorf("invalid protocol format") 133 } 134 a.createRouter() 135 return a, err 136 } 137 138 func setupTCPHTTP(addr string) (*Server, error) { 139 l, err := net.Listen("tcp", addr) 140 if err != nil { 141 return nil, err 142 } 143 144 a := &Server{ 145 HTTPServer: &http.Server{Addr: addr}, 146 Listener: l, 147 } 148 return a, nil 149 } 150 151 func setupUnixHTTP(addr string) (*Server, error) { 152 if err := syscall.Unlink(addr); err != nil && !os.IsNotExist(err) { 153 return nil, err 154 } 155 mask := syscall.Umask(0777) 156 defer syscall.Umask(mask) 157 158 l, err := net.Listen("unix", addr) 159 if err != nil { 160 return nil, err 161 } 162 163 if err := os.Chmod(addr, 0660); err != nil { 164 return nil, err 165 } 166 167 a := &Server{ 168 HTTPServer: &http.Server{Addr: addr}, 169 Listener: l, 170 } 171 return a, nil 172 } 173 174 func logRequestMiddleware(h httprouter.Handle) httprouter.Handle { 175 return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 176 log.Infof("%s %s", r.Method, r.RequestURI) 177 // Delegate request to the given handle 178 h(w, r, p) 179 } 180 } 181 182 // writeJSON writes the value v to the http response stream as json with standard 183 // json encoding. 184 func writeJSON(w http.ResponseWriter, v interface{}, code int) error { 185 w.Header().Set("Content-Type", "application/json") 186 w.WriteHeader(code) 187 return json.NewEncoder(w).Encode(v) 188 } 189 190 func ping(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 191 w.Write([]byte{'P', 'O', 'N', 'G'}) 192 } 193 194 func getVersion(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 195 if err := writeJSON(w, version.New(), http.StatusOK); err != nil { 196 http.Error(w, err.Error(), http.StatusInternalServerError) 197 } 198 } 199 200 func buildApp(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 201 var imagePrefix string 202 baseValues := map[string]interface{}{} 203 appName := p.ByName("id") 204 server := r.Context().Value(contextKey("server")).(*Server) 205 namespace := r.Header.Get("Kubernetes-Namespace") 206 flagWait := r.Header.Get("Helm-Flag-Wait") 207 208 // load client values as the base config 209 log.Debugf("Helm-Flag-Set: %s", r.Header.Get("Helm-Flag-Set")) 210 211 userVals, err := base64.StdEncoding.DecodeString(r.Header.Get("Helm-Flag-Set")) 212 if err != nil { 213 http.Error(w, fmt.Sprintf("error while parsing header 'Helm-Flag-Set': %v\n", err), http.StatusBadRequest) 214 } 215 if err := yaml.Unmarshal([]byte(userVals), &baseValues); err != nil { 216 http.Error(w, fmt.Sprintf("error while unmarshalling header 'Helm-Flag-Set' to yaml: %v\n", err), http.StatusBadRequest) 217 return 218 } 219 220 // NOTE(bacongobbler): If no header was set, we default back to the default namespace. 221 if namespace == "" { 222 namespace = "default" 223 } 224 225 if r.Method != "POST" { 226 w.WriteHeader(http.StatusMethodNotAllowed) 227 return 228 } 229 230 optionWait, err := strconv.ParseBool(flagWait) 231 if err != nil { 232 http.Error(w, fmt.Sprintf("error while parsing header 'Helm-Flag-Wait': %v\n", err), http.StatusBadRequest) 233 return 234 } 235 236 // this is just a buffer of 32MB. Everything is piped over to docker's build context and to 237 // Helm so this is just a sane default in case docker or helm's backed up somehow. 238 r.ParseMultipartForm(32 << 20) 239 buildContext, _, err := r.FormFile("release-tar") 240 if err != nil { 241 http.Error(w, fmt.Sprintf("error while reading release-tar: %v\n", err), http.StatusBadRequest) 242 return 243 } 244 defer buildContext.Close() 245 246 chartFile, _, err := r.FormFile("chart-tar") 247 if err != nil { 248 http.Error(w, fmt.Sprintf("error while reading chart-tar: %v\n", err), http.StatusBadRequest) 249 return 250 } 251 defer chartFile.Close() 252 253 conn, err := WebsocketUpgrader.Upgrade(w, r, nil) 254 if err != nil { 255 http.Error(w, fmt.Sprintf("error when upgrading connection: %v\n", err), http.StatusInternalServerError) 256 return 257 } 258 defer conn.Close() 259 260 conn.SetCloseHandler(func(code int, text string) error { 261 // Note https://tools.ietf.org/html/rfc6455#section-5.5 which specifies control 262 // frames MUST be less than 125 bytes (This includes Close, Ping and Pong) 263 // Hence, sending text as TextMessage and then sending control message. 264 conn.WriteMessage(websocket.TextMessage, []byte(text)) 265 conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, ""), time.Now().Add(time.Second)) 266 return nil 267 }) 268 269 // write build context to a buffer so we can also write to the sha1 hash 270 buf := new(bytes.Buffer) 271 buildContextChecksum := sha1.New() 272 mw := io.MultiWriter(buf, buildContextChecksum) 273 io.Copy(mw, buildContext) 274 275 // truncate checksum to the first 40 characters (20 bytes) 276 // this is the equivalent of `shasum build.tar.gz | awk '{print $1}'` 277 tag := fmt.Sprintf("%.20x", buildContextChecksum.Sum(nil)) 278 if server.RegistryOrg != "" { 279 imagePrefix = server.RegistryOrg + "/" 280 } 281 imageName := fmt.Sprintf("%s/%s%s:%s", 282 server.RegistryURL, 283 imagePrefix, 284 appName, 285 tag, 286 ) 287 288 // inject certain values into the chart such as the registry location, the application name 289 // and the version 290 imageVals := fmt.Sprintf("image.name=%s,image.org=%s,image.registry=%s,image.tag=%s", 291 appName, 292 server.RegistryOrg, 293 server.RegistryURL, 294 tag) 295 296 if err := strvals.ParseInto(imageVals, baseValues); err != nil { 297 handleClosingError(conn, "Could not inject registry data into values", err) 298 } 299 300 rawVals, err := yaml.Marshal(baseValues) 301 if err != nil { 302 handleClosingError(conn, "Could not marshal values", err) 303 } 304 305 // send uploaded tar to docker as the build context 306 conn.WriteMessage(websocket.TextMessage, []byte("--> Building Dockerfile\n")) 307 buildResp, err := server.DockerClient.ImageBuild( 308 context.Background(), 309 buf, 310 types.ImageBuildOptions{ 311 Tags: []string{imageName}, 312 }) 313 if err != nil { 314 handleClosingError(conn, "Could not build image from build context", err) 315 } 316 defer buildResp.Body.Close() 317 writer, err := conn.NextWriter(websocket.TextMessage) 318 if err != nil { 319 handleClosingError(conn, "There was an error fetching a text message writer", err) 320 } 321 outFd, isTerm := term.GetFdInfo(writer) 322 if err := jsonmessage.DisplayJSONMessagesStream(buildResp.Body, writer, outFd, isTerm, nil); err != nil { 323 handleClosingError(conn, "Error encountered streaming JSON response", err) 324 } 325 326 _, _, err = server.DockerClient.ImageInspectWithRaw( 327 context.Background(), 328 imageName) 329 if err != nil { 330 if docker.IsErrImageNotFound(err) { 331 handleClosingError(conn, fmt.Sprintf("Could not locate image for %s", appName), err) 332 } else { 333 handleClosingError(conn, "ImageInspectWithRaw error", err) 334 } 335 } 336 337 conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("--> Pushing %s\n", imageName))) 338 pushResp, err := server.DockerClient.ImagePush( 339 context.Background(), 340 imageName, 341 types.ImagePushOptions{RegistryAuth: server.RegistryAuth}) 342 if err != nil { 343 handleClosingError(conn, fmt.Sprintf("Could not push %s to registry", imageName), err) 344 } 345 defer pushResp.Close() 346 writer, err = conn.NextWriter(websocket.TextMessage) 347 if err != nil { 348 handleClosingError(conn, "There was an error fetching a text message writer", err) 349 } 350 outFd, isTerm = term.GetFdInfo(writer) 351 if err := jsonmessage.DisplayJSONMessagesStream(pushResp, writer, outFd, isTerm, nil); err != nil { 352 handleClosingError(conn, "Error encountered streaming JSON response", err) 353 } 354 355 conn.WriteMessage(websocket.TextMessage, []byte("--> Deploying to Kubernetes\n")) 356 chart, err := chartutil.LoadArchive(chartFile) 357 if err != nil { 358 handleClosingError(conn, "Could not load chart archive", err) 359 } 360 361 // combinedVars takes the basedomain configured in draftd and appends that to the rawVals 362 combinedVars := append([]byte(fmt.Sprintf("basedomain: %s\n", server.Basedomain))[:], []byte(rawVals)[:]...) 363 364 // If a release does not exist, install it. If another error occurs during 365 // the check, ignore the error and continue with the upgrade. 366 // 367 // The returned error is a grpc.rpcError that wraps the message from the original error. 368 // So we're stuck doing string matching against the wrapped error, which is nested somewhere 369 // inside of the grpc.rpcError message. 370 _, err = server.HelmClient.ReleaseContent(appName, helm.ContentReleaseVersion(1)) 371 if err != nil && strings.Contains(err.Error(), driver.ErrReleaseNotFound.Error()) { 372 conn.WriteMessage( 373 websocket.TextMessage, 374 []byte(fmt.Sprintf(" Release %q does not exist. Installing it now.\n", appName))) 375 releaseResp, err := server.HelmClient.InstallReleaseFromChart( 376 chart, 377 namespace, 378 helm.ReleaseName(appName), 379 helm.ValueOverrides(combinedVars), 380 helm.InstallWait(optionWait)) 381 if err != nil { 382 handleClosingError(conn, "Could not install release", err) 383 } 384 conn.WriteMessage( 385 websocket.TextMessage, 386 formatReleaseStatus(releaseResp.Release)) 387 } else { 388 releaseResp, err := server.HelmClient.UpdateReleaseFromChart( 389 appName, 390 chart, 391 helm.UpdateValueOverrides(combinedVars), 392 helm.UpgradeWait(optionWait)) 393 if err != nil { 394 handleClosingError(conn, "Could not upgrade release", err) 395 } 396 conn.WriteMessage( 397 websocket.TextMessage, 398 formatReleaseStatus(releaseResp.Release)) 399 } 400 401 // gently tell the client that we are closing the connection 402 conn.WriteControl( 403 websocket.CloseMessage, 404 websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), 405 time.Now().Add(time.Second)) 406 } 407 408 // handleClosingError formats the err and corresponding verbiage and invokes 409 // conn.CloseHandler() as set by conn.SetCloseHandler() 410 func handleClosingError(conn *websocket.Conn, verbiage string, err error) { 411 conn.CloseHandler()( 412 websocket.CloseInternalServerErr, 413 fmt.Sprintf("%s: %v\n", verbiage, err)) 414 } 415 416 // formatReleaseStatus returns a byte slice of formatted release status information 417 func formatReleaseStatus(release *release.Release) []byte { 418 output := fmt.Sprintf("--> Status: %s\n", release.Info.Status.Code.String()) 419 if release.Info.Status.Notes != "" { 420 output += fmt.Sprintf("--> Notes:\n %s\n", release.Info.Status.Notes) 421 } 422 return []byte(output) 423 }