378 lines
11 KiB
Go
378 lines
11 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/cas"
|
|
"github.com/iceBear67/simplepages/internal/httpx"
|
|
"github.com/iceBear67/simplepages/internal/pathutil"
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// Caps on the deployment metadata a CI job may attach. Generous enough for a
|
|
// commit sha, a branch, a run URL and an actor; small enough that the column
|
|
// cannot become a place to store things.
|
|
const (
|
|
maxMetaEntries = 32
|
|
maxMetaKeyLen = 64
|
|
maxMetaValueLen = 512
|
|
)
|
|
|
|
// createDeployment handles POST /api/v1/projects/{name}/deployments.
|
|
func (s *Server) createDeployment(w http.ResponseWriter, r *http.Request) error {
|
|
p, err := s.project(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The body is optional: a deployment that carries no metadata is a bare POST,
|
|
// which is what `curl -X POST` and any shell-driven CI job send.
|
|
var req api.CreateDeploymentRequest
|
|
if r.ContentLength != 0 {
|
|
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := checkMeta(req.Meta); err != nil {
|
|
return err
|
|
}
|
|
id, err := s.identity(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dep, err := s.Deploy.Create(r.Context(), p, id.KeyID, req.Meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
|
|
w.Header().Set("Location", api.PathDeployment(p.Name, dep.PublicID))
|
|
httpx.WriteJSON(w, http.StatusCreated, deploymentOf(p, dep))
|
|
return nil
|
|
}
|
|
|
|
// setManifest handles POST .../deployments/{id}/manifest.
|
|
//
|
|
// The body is walked one entry at a time rather than unmarshalled whole: a
|
|
// 50,000-file manifest would otherwise be resident twice over, once as raw JSON
|
|
// and once as structs, for no benefit — nothing here needs to see the entries
|
|
// together.
|
|
func (s *Server) setManifest(w http.ResponseWriter, r *http.Request) error {
|
|
p, dep, err := s.deployment(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.maxManifest())
|
|
var (
|
|
files []store.FileRow
|
|
paths = pathutil.NewSet(0)
|
|
unique = make(map[cas.Digest]struct{})
|
|
totalBytes int64
|
|
)
|
|
err = decodeManifest(json.NewDecoder(r.Body), func(f api.FileEntry) error {
|
|
if err := paths.Add(f.Path); err != nil {
|
|
return api.Errorf(api.CodeInvalidPath, "%s", err)
|
|
}
|
|
digest, err := cas.ParseDigest(f.Digest)
|
|
if err != nil {
|
|
return api.Errorf(api.CodeBadRequest, "%s: %s", f.Path, err)
|
|
}
|
|
if f.Size < 0 {
|
|
return api.Errorf(api.CodeBadRequest, "%s: size must not be negative", f.Path)
|
|
}
|
|
if f.Size > p.MaxFileBytes {
|
|
return api.Errorf(api.CodeLimitExceeded,
|
|
"%s is %d bytes; this project allows at most %d per file", f.Path, f.Size, p.MaxFileBytes)
|
|
}
|
|
if len(files) >= p.MaxFiles {
|
|
return api.Errorf(api.CodeLimitExceeded,
|
|
"a deployment of this project may contain at most %d files", p.MaxFiles)
|
|
}
|
|
totalBytes += f.Size
|
|
if totalBytes > p.MaxTotalBytes {
|
|
return api.Errorf(api.CodeLimitExceeded,
|
|
"a deployment of this project may total at most %d bytes", p.MaxTotalBytes)
|
|
}
|
|
unique[digest] = struct{}{}
|
|
files = append(files, store.FileRow{Path: f.Path, Digest: digest, Size: f.Size})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(files) == 0 {
|
|
return api.Errorf(api.CodeBadRequest, "a manifest must list at least one file")
|
|
}
|
|
|
|
missing, missingBytes, err := s.Deploy.SetManifest(r.Context(), dep, files)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := api.ManifestResponse{
|
|
Missing: make([]string, 0, len(missing)),
|
|
MissingBytes: missingBytes,
|
|
Have: len(unique) - len(missing),
|
|
FileCount: len(files),
|
|
TotalBytes: totalBytes,
|
|
}
|
|
for _, d := range missing {
|
|
out.Missing = append(out.Missing, d.String())
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// finalize handles POST .../deployments/{id}/finalize.
|
|
func (s *Server) finalize(w http.ResponseWriter, r *http.Request) error {
|
|
p, dep, err := s.deployment(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := httpx.NoBody(r); err != nil {
|
|
return err
|
|
}
|
|
dep, err = s.Deploy.Finalize(r.Context(), p, dep)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, deploymentOf(p, dep))
|
|
return nil
|
|
}
|
|
|
|
// activate handles POST .../deployments/{id}/activate.
|
|
//
|
|
// This is also the rollback endpoint: activating an older ready deployment is
|
|
// the same operation, and costs the same single pointer store.
|
|
func (s *Server) activate(w http.ResponseWriter, r *http.Request) error {
|
|
p, dep, err := s.deployment(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := httpx.NoBody(r); err != nil {
|
|
return err
|
|
}
|
|
dep, err = s.Deploy.Activate(r.Context(), p, dep)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := deploymentOf(p, dep)
|
|
if s.BaseURL != "" {
|
|
out.URL = api.SiteURL(s.BaseURL, p.Name)
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// listDeployments handles GET .../deployments.
|
|
func (s *Server) listDeployments(w http.ResponseWriter, r *http.Request) error {
|
|
p, err := s.project(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
limit, err := intQuery(r, "limit", 100, 1, 500)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state, err := parseState(r.URL.Query().Get("state"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
deps, next, err := s.DB.ListDeployments(r.Context(), p.ID, state, limit, r.URL.Query().Get("cursor"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := api.DeploymentList{Deployments: make([]api.Deployment, 0, len(deps)), NextCursor: next}
|
|
for _, dep := range deps {
|
|
out.Deployments = append(out.Deployments, deploymentOf(p, dep))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// getDeployment handles GET .../deployments/{id}, with ?files=true adding the
|
|
// manifest.
|
|
//
|
|
// The manifest is opt-in because it can be fifty thousand entries: a listing
|
|
// that carried it by default would make `pages deployment list` unusable on a
|
|
// large site for information nobody asked for.
|
|
func (s *Server) getDeployment(w http.ResponseWriter, r *http.Request) error {
|
|
p, dep, err := s.deployment(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := deploymentOf(p, dep)
|
|
if r.URL.Query().Get("files") == "true" {
|
|
files, err := s.DB.DeploymentFiles(r.Context(), dep.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out.Files = make([]api.FileEntry, 0, len(files))
|
|
for _, f := range files {
|
|
out.Files = append(out.Files, api.FileEntry{
|
|
Path: f.Path, Digest: f.Digest.String(), Size: f.Size,
|
|
})
|
|
}
|
|
}
|
|
if dep.Active && s.BaseURL != "" {
|
|
out.URL = api.SiteURL(s.BaseURL, p.Name)
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// deleteDeployment handles DELETE .../deployments/{id}. Deleting the active one
|
|
// is a 409: the client is expected to activate something else first, so that
|
|
// the project is never left with nothing to serve by accident.
|
|
func (s *Server) deleteDeployment(w http.ResponseWriter, r *http.Request) error {
|
|
if err := httpx.NoBody(r); err != nil {
|
|
return err
|
|
}
|
|
p, dep, err := s.deployment(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.Deploy.Delete(r.Context(), p, dep); err != nil {
|
|
return err
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
// parseState validates the ?state= filter. The empty string means no filter;
|
|
// anything else must be a state that exists, so a typo is a 400 rather than a
|
|
// silently empty list.
|
|
func parseState(raw string) (store.State, error) {
|
|
switch raw {
|
|
case "":
|
|
return "", nil
|
|
case api.StatePending, api.StateUploading, api.StateReady, api.StateFailed, api.StateDeleting:
|
|
return store.State(raw), nil
|
|
}
|
|
return "", api.Errorf(api.CodeBadRequest, "unknown deployment state %q", raw)
|
|
}
|
|
|
|
// deployment resolves both the {name} and {id} wildcards.
|
|
//
|
|
// The deployment is looked up within the project, never on its own: a
|
|
// project-scoped caller that guessed another project's deployment id gets the
|
|
// same "no such deployment" as one that guessed a nonexistent one.
|
|
func (s *Server) deployment(r *http.Request) (*store.Project, *store.Deployment, error) {
|
|
p, err := s.project(r)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
dep, err := s.DB.DeploymentByPublicID(r.Context(), p.ID, r.PathValue("id"))
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, nil, api.Errorf(api.CodeNotFound, "no such deployment")
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
|
|
return p, dep, nil
|
|
}
|
|
|
|
// decodeManifest streams {"files":[...]} and calls onFile for each entry.
|
|
func decodeManifest(dec *json.Decoder, onFile func(api.FileEntry) error) error {
|
|
if err := expectDelim(dec, '{', "manifest must be a JSON object"); err != nil {
|
|
return err
|
|
}
|
|
seen := false
|
|
for dec.More() {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return badJSON(err)
|
|
}
|
|
key, _ := tok.(string)
|
|
if key != "files" {
|
|
// Unknown keys are skipped rather than rejected: a newer CLI may send
|
|
// a field this server predates, and the body is bounded anyway.
|
|
var skip json.RawMessage
|
|
if err := dec.Decode(&skip); err != nil {
|
|
return badJSON(err)
|
|
}
|
|
continue
|
|
}
|
|
seen = true
|
|
if err := expectDelim(dec, '[', "files must be an array"); err != nil {
|
|
return err
|
|
}
|
|
for dec.More() {
|
|
var f api.FileEntry
|
|
if err := dec.Decode(&f); err != nil {
|
|
return badJSON(err)
|
|
}
|
|
if err := onFile(f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := dec.Token(); err != nil { // closing ]
|
|
return badJSON(err)
|
|
}
|
|
}
|
|
if _, err := dec.Token(); err != nil { // closing }
|
|
return badJSON(err)
|
|
}
|
|
if !seen {
|
|
return api.Errorf(api.CodeBadRequest, "manifest is missing the files array")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func expectDelim(dec *json.Decoder, want json.Delim, msg string) error {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return badJSON(err)
|
|
}
|
|
if d, ok := tok.(json.Delim); !ok || d != want {
|
|
return api.Errorf(api.CodeBadRequest, "%s", msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// badJSON turns a decoder failure into a client error, keeping the body-size
|
|
// rejection distinguishable from a syntax one.
|
|
func badJSON(err error) error {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
return api.Errorf(api.CodePayloadTooLarge, "manifest exceeds %d bytes", maxErr.Limit)
|
|
}
|
|
var syn *json.SyntaxError
|
|
if errors.As(err, &syn) {
|
|
return api.Errorf(api.CodeBadRequest, "malformed JSON at byte %d", syn.Offset)
|
|
}
|
|
var typeErr *json.UnmarshalTypeError
|
|
if errors.As(err, &typeErr) {
|
|
return api.Errorf(api.CodeBadRequest, "field %q: want %s", typeErr.Field, typeErr.Type)
|
|
}
|
|
return api.Errorf(api.CodeBadRequest, "malformed manifest")
|
|
}
|
|
|
|
func checkMeta(meta map[string]string) error {
|
|
if len(meta) > maxMetaEntries {
|
|
return api.Errorf(api.CodeBadRequest, "meta may hold at most %d entries", maxMetaEntries)
|
|
}
|
|
for k, v := range meta {
|
|
if k == "" {
|
|
return api.Errorf(api.CodeBadRequest, "meta keys must not be empty")
|
|
}
|
|
if err := checkText("meta key", k, maxMetaKeyLen); err != nil {
|
|
return err
|
|
}
|
|
if err := checkText("meta value of "+k, v, maxMetaValueLen); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) maxManifest() int64 {
|
|
if s.Limits.MaxManifestBytes > 0 {
|
|
return s.Limits.MaxManifestBytes
|
|
}
|
|
return 64 << 20
|
|
}
|