diff --git a/cmd/influxd/launcher/launcher.go b/cmd/influxd/launcher/launcher.go index a55b072692..b10ee48ac1 100644 --- a/cmd/influxd/launcher/launcher.go +++ b/cmd/influxd/launcher/launcher.go @@ -1019,10 +1019,22 @@ func (m *Launcher) run(ctx context.Context) (err error) { pkgSVC = pkger.MWAuth(authAgent)(pkgSVC) } - var pkgHTTPServer *pkger.HTTPServerPackages + var pkgHTTPServerDeprecated *pkger.HTTPServerPackages { pkgServerLogger := m.log.With(zap.String("handler", "pkger")) - pkgHTTPServer = pkger.NewHTTPServerPackages(pkgServerLogger, pkgSVC) + pkgHTTPServerDeprecated = pkger.NewHTTPServerPackages(pkgServerLogger, pkgSVC) + } + + var stacksHTTPServer *pkger.HTTPServerStacks + { + tLogger := m.log.With(zap.String("handler", "stacks")) + stacksHTTPServer = pkger.NewHTTPServerStacks(tLogger, pkgSVC) + } + + var templatesHTTPServer *pkger.HTTPServerTemplates + { + tLogger := m.log.With(zap.String("handler", "templates")) + templatesHTTPServer = pkger.NewHTTPServerTemplates(tLogger, pkgSVC) } var userHTTPServer *tenant.UserHandler @@ -1093,7 +1105,9 @@ func (m *Launcher) run(ctx context.Context) (err error) { { platformHandler := http.NewPlatformHandler(m.apibackend, - http.WithResourceHandler(pkgHTTPServer), + http.WithResourceHandler(pkgHTTPServerDeprecated), + http.WithResourceHandler(stacksHTTPServer), + http.WithResourceHandler(templatesHTTPServer), http.WithResourceHandler(onboardHTTPServer), http.WithResourceHandler(authHTTPServer), http.WithResourceHandler(kithttp.NewFeatureHandler(feature.NewLabelPackage(), m.flagger, oldLabelHandler, labelHandler, labelHandler.Prefix())), diff --git a/http/swagger.yml b/http/swagger.yml index ec638806ad..c59d93c93a 100644 --- a/http/swagger.yml +++ b/http/swagger.yml @@ -4621,8 +4621,6 @@ paths: /packages: post: operationId: CreatePkg - tags: - - InfluxPackages summary: Create a new Influx package requestBody: description: Influx package to create. @@ -4650,8 +4648,6 @@ paths: /packages/apply: post: operationId: ApplyPkg - tags: - - InfluxPackages summary: Apply or dry-run an Influx package requestBody: required: true @@ -4694,8 +4690,6 @@ paths: /packages/stacks: get: operationId: ListStacks - tags: - - InfluxPackages summary: Grab a list of installed Influx packages parameters: - in: query @@ -4734,8 +4728,6 @@ paths: $ref: "#/components/schemas/Error" post: operationId: CreateStack - tags: - - InfluxPackages summary: Create a new stack requestBody: description: Influx stack to create. @@ -4771,8 +4763,6 @@ paths: /packages/stacks/{stack_id}: get: operationId: ReadStack - tags: - - InfluxPackages summary: Grab a stack by its ID parameters: - in: path @@ -4796,8 +4786,6 @@ paths: $ref: "#/components/schemas/Error" patch: operationId: UpdateStack - tags: - - InfluxPackages summary: Update a an Influx Stack parameters: - in: path @@ -4849,8 +4837,6 @@ paths: $ref: "#/components/schemas/Error" delete: operationId: DeleteStack - tags: - - InfluxPackages summary: Delete a stack and remove all its associated resources parameters: - in: path @@ -4874,6 +4860,262 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /stacks: + get: + operationId: ListStacks + tags: + - InfluxDB Templates + summary: Grab a list of installed InfluxDB Templates + parameters: + - in: query + name: orgID + required: true + schema: + type: string + description: The organization id of the stacks + - in: query + name: name + schema: + type: string + description: A collection of names to filter the list by. + - in: query + name: stackID + schema: + type: string + description: A collection of stackIDs to filter the list by. + responses: + "200": + description: Influx stacks found + content: + application/json: + schema: + type: object + properties: + stacks: + type: array + items: + $ref: "#/components/schemas/Stack" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + operationId: CreateStack + tags: + - InfluxDB Templates + summary: Create a new stack + requestBody: + description: Stack to create. + required: true + content: + application/json: + schema: + type: object + properties: + orgID: + type: string + name: + type: string + description: + type: string + urls: + type: array + items: + type: string + responses: + "201": + description: InfluxDB Stack created + content: + application/json: + schema: + $ref: "#/components/schemas/Stack" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /stacks/{stack_id}: + get: + operationId: ReadStack + tags: + - InfluxDB Templates + summary: Grab a stack by its ID + parameters: + - in: path + name: stack_id + required: true + schema: + type: string + description: The stack id + responses: + "200": + description: Read an influx stack by ID + content: + application/json: + schema: + $ref: "#/components/schemas/Stack" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + operationId: UpdateStack + tags: + - InfluxDB Templates + summary: Update a an InfluxDB Stack + parameters: + - in: path + name: stack_id + required: true + schema: + type: string + description: The stack id + requestBody: + description: Influx stack to update. + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + templateURLs: + type: array + items: + type: string + additionalResources: + type: array + items: + type: object + properties: + resourceID: + type: string + kind: + type: string + templateMetaName: + type: string + required: ["kind","resourceID"] + responses: + "200": + description: Influx stack updated + content: + application/json: + schema: + $ref: "#/components/schemas/Stack" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + operationId: DeleteStack + tags: + - InfluxDB Templates + summary: Delete a stack and remove all its associated resources + parameters: + - in: path + name: stack_id + required: true + schema: + type: string + description: The stack id + - in: query + name: orgID + required: true + schema: + type: string + description: The organization id of the user + responses: + "204": + description: Stack and all its associated resources are deleted + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /templates/apply: + post: + operationId: ApplyTemplate + tags: + - InfluxDB Templates + summary: Apply or dry-run an InfluxDB Template + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PkgApply" + application/x-jsonnet: + schema: + $ref: "#/components/schemas/PkgApply" + text/yml: + schema: + $ref: "#/components/schemas/PkgApply" + responses: + "200": + description: > + Influx package dry-run successful, no new resources created. + The provided diff and summary will not have IDs for resources + that do not exist at the time of the dry run. + content: + application/json: + schema: + $ref: "#/components/schemas/PkgSummary" + "201": + description: > + Influx package applied successfully. Newly created resources created + available in summary. The diff compares the state of the world before + the package is applied with the changes the application will impose. + This corresponds to `"dryRun": true` + content: + application/json: + schema: + $ref: "#/components/schemas/PkgSummary" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /templates/export: + post: + operationId: ExportTemplate + tags: + - InfluxDB Templates + summary: Export a new Influx Template + requestBody: + description: Export resources as an InfluxDB template. + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/PkgCreate" + responses: + "200": + description: InfluxDB template created + content: + application/json: + schema: + $ref: "#/components/schemas/Pkg" + application/x-yaml: + schema: + $ref: "#/components/schemas/Pkg" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /tasks: get: operationId: GetTasks diff --git a/pkger/http_server_packages_deprecated.go b/pkger/http_server_packages_deprecated.go index 572c61bac9..d472136fa7 100644 --- a/pkger/http_server_packages_deprecated.go +++ b/pkger/http_server_packages_deprecated.go @@ -1,22 +1,14 @@ package pkger import ( - "bytes" - "encoding/json" "fmt" - "io" "net/http" - "net/url" - "path" - "strings" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/influxdata/influxdb/v2" pctx "github.com/influxdata/influxdb/v2/context" - ierrors "github.com/influxdata/influxdb/v2/kit/errors" kithttp "github.com/influxdata/influxdb/v2/kit/transport/http" - "github.com/influxdata/influxdb/v2/pkg/jsonnet" "go.uber.org/zap" "gopkg.in/yaml.v3" ) @@ -44,6 +36,9 @@ func NewHTTPServerPackages(log *zap.Logger, svc SVC) *HTTPServerPackages { setJSONContentType := middleware.SetHeader("Content-Type", "application/json; charset=utf-8") r := chi.NewRouter() + r.Use( + middleware.SetHeader("Sunset", "Thurs, 30 July 2020 17:00:00 UTC"), + ) { r.With(exportAllowContentTypes).Post("/", svr.export) r.With(setJSONContentType).Post("/apply", svr.apply) @@ -56,13 +51,7 @@ func NewHTTPServerPackages(log *zap.Logger, svc SVC) *HTTPServerPackages { r.Get("/", svr.readStack) r.Delete("/", svr.deleteStack) r.Patch("/", svr.updateStack) - r.With( - exportAllowContentTypes, - // sunsetting, will not appear as part of the swagger doc and will be dropped - // in the near term. The sunset header follows the following IETF RFC: - // https://tools.ietf.org/html/rfc8594 - middleware.SetHeader("Sunset", "Thurs, 30 July 2020 17:00:00 UTC"), - ).Get("/export", svr.exportStack) + r.With(exportAllowContentTypes).Get("/export", svr.exportStack) }) }) } @@ -76,50 +65,6 @@ func (s *HTTPServerPackages) Prefix() string { return RoutePrefixPackages } -type ( - // RespStack is the response body for a stack. - RespStack struct { - ID string `json:"id"` - OrgID string `json:"orgID"` - Name string `json:"name"` - Description string `json:"description"` - Resources []RespStackResource `json:"resources"` - Sources []string `json:"sources"` - URLs []string `json:"urls"` - influxdb.CRUDLog - } - - // RespStackResource is the response for a stack resource. This type exists - // to decouple the internal service implementation from the deprecates usage - // of pkgs in the API. We could add a custom UnmarshalJSON method, but - // I would rather keep it obvious and explicit with a separate field. - RespStackResource struct { - APIVersion string `json:"apiVersion"` - ID string `json:"resourceID"` - Kind Kind `json:"kind"` - MetaName string `json:"templateMetaName"` - Associations []RespStackResourceAssoc `json:"associations"` - - // PkgName is deprecated moving forward, will support until it is - // ripped out. - PkgName *string `json:"pkgName,omitempty"` - } - - // RespStackResourceAssoc is the response for a stack resource's associations. - RespStackResourceAssoc struct { - Kind Kind `json:"kind"` - MetaName string `json:"metaName"` - - //PkgName is to be deprecated moving forward - PkgName *string `json:"pkgName,omitempty"` - } -) - -// RespListStacks is the HTTP response for a stack list call. -type RespListStacks struct { - Stacks []RespStack `json:"stacks"` -} - func (s *HTTPServerPackages) listStacks(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() @@ -179,40 +124,6 @@ func (s *HTTPServerPackages) listStacks(w http.ResponseWriter, r *http.Request) }) } -// ReqCreateStack is a request body for a create stack call. -type ReqCreateStack struct { - OrgID string `json:"orgID"` - Name string `json:"name"` - Description string `json:"description"` - URLs []string `json:"urls"` -} - -// OK validates the request body is valid. -func (r *ReqCreateStack) OK() error { - // TODO: provide multiple errors back for failing validation - if _, err := influxdb.IDFromString(r.OrgID); err != nil { - return &influxdb.Error{ - Code: influxdb.EInvalid, - Msg: fmt.Sprintf("provided org id[%q] is invalid", r.OrgID), - } - } - - for _, u := range r.URLs { - if _, err := url.Parse(u); err != nil { - return &influxdb.Error{ - Code: influxdb.EInvalid, - Msg: fmt.Sprintf("provided url[%q] is invalid", u), - } - } - } - return nil -} - -func (r *ReqCreateStack) orgID() influxdb.ID { - orgID, _ := influxdb.IDFromString(r.OrgID) - return *orgID -} - func (s *HTTPServerPackages) createStack(w http.ResponseWriter, r *http.Request) { var reqBody ReqCreateStack if err := s.api.DecodeJSON(r.Body, &reqBody); err != nil { @@ -321,26 +232,6 @@ func (s *HTTPServerPackages) readStack(w http.ResponseWriter, r *http.Request) { s.api.Respond(w, r, http.StatusOK, convertStackToRespStack(stack)) } -type ( - // ReqUpdateStack is the request body for updating a stack. - ReqUpdateStack struct { - Name *string `json:"name"` - Description *string `json:"description"` - TemplateURLs []string `json:"templateURLs"` - AdditionalResources []ReqUpdateStackResource `json:"additionalResources"` - - // Deprecating the urls field and replacing with templateURLs field. - // This is remaining here for backwards compatibility. - URLs []string `json:"urls"` - } - - ReqUpdateStackResource struct { - ID string `json:"resourceID"` - Kind Kind `json:"kind"` - MetaName string `json:"templateMetaName"` - } -) - func (s *HTTPServerPackages) updateStack(w http.ResponseWriter, r *http.Request) { var req ReqUpdateStack if err := s.api.DecodeJSON(r.Body, &req); err != nil { @@ -383,82 +274,6 @@ func (s *HTTPServerPackages) updateStack(w http.ResponseWriter, r *http.Request) s.api.Respond(w, r, http.StatusOK, convertStackToRespStack(stack)) } -func stackIDFromReq(r *http.Request) (influxdb.ID, error) { - stackID, err := influxdb.IDFromString(chi.URLParam(r, "stack_id")) - if err != nil { - return 0, &influxdb.Error{ - Code: influxdb.EInvalid, - Msg: "the stack id provided in the path was invalid", - Err: err, - } - } - return *stackID, nil -} - -func getRequiredOrgIDFromQuery(q url.Values) (influxdb.ID, error) { - orgIDRaw := q.Get("orgID") - if orgIDRaw == "" { - return 0, &influxdb.Error{ - Code: influxdb.EInvalid, - Msg: "the orgID query param is required", - } - } - - orgID, err := influxdb.IDFromString(orgIDRaw) - if err != nil { - return 0, &influxdb.Error{ - Code: influxdb.EInvalid, - Msg: "the orgID query param was invalid", - Err: err, - } - } - return *orgID, nil -} - -// ReqExportOrgIDOpt provides options to export resources by organization id. -type ReqExportOrgIDOpt struct { - OrgID string `json:"orgID"` - Filters struct { - ByLabel []string `json:"byLabel"` - ByResourceKind []Kind `json:"byResourceKind"` - } `json:"resourceFilters"` -} - -// ReqExport is a request body for the export endpoint. -type ReqExport struct { - StackID string `json:"stackID"` - OrgIDs []ReqExportOrgIDOpt `json:"orgIDs"` - Resources []ResourceToClone `json:"resources"` -} - -// OK validates a create request. -func (r *ReqExport) OK() error { - if len(r.Resources) == 0 && len(r.OrgIDs) == 0 && r.StackID == "" { - return &influxdb.Error{ - Code: influxdb.EUnprocessableEntity, - Msg: "at least 1 resource, 1 org id, or stack id must be provided", - } - } - - for _, org := range r.OrgIDs { - if _, err := influxdb.IDFromString(org.OrgID); err != nil { - return &influxdb.Error{ - Code: influxdb.EInvalid, - Msg: fmt.Sprintf("provided org id is invalid: %q", org.OrgID), - } - } - } - - if r.StackID != "" { - _, err := influxdb.IDFromString(r.StackID) - return err - } - return nil -} - -// RespExport is a response body for the create pkg endpoint. -type RespExport []Object - func (s *HTTPServerPackages) export(w http.ResponseWriter, r *http.Request) { var reqBody ReqExport if err := s.api.DecodeJSON(r.Body, &reqBody); err != nil { @@ -518,187 +333,6 @@ func (s *HTTPServerPackages) export(w http.ResponseWriter, r *http.Request) { s.encResp(w, r, enc, http.StatusOK, resp) } -// ReqTemplateRemote provides a package via a remote (i.e. a gist). If content type is not -// provided then the service will do its best to discern the content type of the -// contents. -type ReqTemplateRemote struct { - URL string `json:"url" yaml:"url"` - ContentType string `json:"contentType" yaml:"contentType"` -} - -// Encoding returns the encoding type that corresponds to the given content type. -func (p ReqTemplateRemote) Encoding() Encoding { - return convertEncoding(p.ContentType, p.URL) -} - -type ReqRawTemplate struct { - ContentType string `json:"contentType" yaml:"contentType"` - Sources []string `json:"sources" yaml:"sources"` - Pkg json.RawMessage `json:"contents" yaml:"contents"` -} - -func (p ReqRawTemplate) Encoding() Encoding { - var source string - if len(p.Sources) > 0 { - source = p.Sources[0] - } - return convertEncoding(p.ContentType, source) -} - -// ReqRawAction is a raw action consumers can provide to change the behavior -// of the application of a template. -type ReqRawAction struct { - Action string `json:"action"` - Properties json.RawMessage `json:"properties"` -} - -// ReqApply is the request body for a json or yaml body for the apply pkg endpoint. -type ReqApply struct { - DryRun bool `json:"dryRun" yaml:"dryRun"` - OrgID string `json:"orgID" yaml:"orgID"` - StackID *string `json:"stackID" yaml:"stackID"` // optional: non nil value signals stack should be used - Remotes []ReqTemplateRemote `json:"remotes" yaml:"remotes"` - - // TODO(jsteenb2): pkg references will all be replaced by template references - // these 2 exist alongside the templates for backwards compatibility - // until beta13 rolls out the door. This code should get axed when the next - // OSS release goes out. - RawPkgs []json.RawMessage `json:"packages" yaml:"packages"` - RawPkg json.RawMessage `json:"package" yaml:"package"` - - RawTemplates []ReqRawTemplate `json:"templates" yaml:"templates"` - RawTemplate ReqRawTemplate `json:"template" yaml:"template"` - - EnvRefs map[string]string `json:"envRefs"` - Secrets map[string]string `json:"secrets"` - - RawActions []ReqRawAction `json:"actions"` -} - -// Pkgs returns all pkgs associated with the request. -func (r ReqApply) Pkgs(encoding Encoding) (*Pkg, error) { - var rawPkgs []*Pkg - for _, rem := range r.Remotes { - if rem.URL == "" { - continue - } - pkg, err := Parse(rem.Encoding(), FromHTTPRequest(rem.URL), ValidSkipParseError()) - if err != nil { - return nil, &influxdb.Error{ - Code: influxdb.EUnprocessableEntity, - Msg: fmt.Sprintf("pkg from url[%s] had an issue: %s", rem.URL, err.Error()), - } - } - rawPkgs = append(rawPkgs, pkg) - } - - for i, rawPkg := range append(r.RawPkgs, r.RawPkg) { - if rawPkg == nil { - continue - } - - pkg, err := Parse(encoding, FromReader(bytes.NewReader(rawPkg)), ValidSkipParseError()) - if err != nil { - return nil, &influxdb.Error{ - Code: influxdb.EUnprocessableEntity, - Msg: fmt.Sprintf("pkg[%d] had an issue: %s", i, err.Error()), - } - } - rawPkgs = append(rawPkgs, pkg) - } - - for i, rawTmpl := range append(r.RawTemplates, r.RawTemplate) { - if rawTmpl.Pkg == nil { - continue - } - enc := encoding - if sourceEncoding := rawTmpl.Encoding(); sourceEncoding != EncodingSource { - enc = sourceEncoding - } - pkg, err := Parse(enc, FromReader(bytes.NewReader(rawTmpl.Pkg), rawTmpl.Sources...), ValidSkipParseError()) - if err != nil { - sources := formatSources(rawTmpl.Sources) - return nil, &influxdb.Error{ - Code: influxdb.EUnprocessableEntity, - Msg: fmt.Sprintf("pkg[%d] from source(s) %q had an issue: %s", i, sources, err.Error()), - } - } - rawPkgs = append(rawPkgs, pkg) - } - - return Combine(rawPkgs, ValidWithoutResources(), ValidSkipParseError()) -} - -type actionType string - -// various ActionTypes the transport API speaks -const ( - ActionTypeSkipKind actionType = "skipKind" - ActionTypeSkipResource actionType = "skipResource" -) - -func (r ReqApply) validActions() (struct { - SkipKinds []ActionSkipKind - SkipResources []ActionSkipResource -}, error) { - type actions struct { - SkipKinds []ActionSkipKind - SkipResources []ActionSkipResource - } - - unmarshalErrFn := func(err error, idx int, actionType string) error { - msg := fmt.Sprintf("failed to unmarshal properties for actions[%d] %q", idx, actionType) - return ierrors.Wrap(err, msg) - } - - kindErrFn := func(err error, idx int, actionType string) error { - msg := fmt.Sprintf("invalid kind for actions[%d] %q", idx, actionType) - return ierrors.Wrap(err, msg) - } - - var out actions - for i, rawAct := range r.RawActions { - switch a := rawAct.Action; actionType(a) { - case ActionTypeSkipResource: - var asr ActionSkipResource - if err := json.Unmarshal(rawAct.Properties, &asr); err != nil { - return actions{}, influxErr(influxdb.EInvalid, unmarshalErrFn(err, i, a)) - } - if err := asr.Kind.OK(); err != nil { - return actions{}, influxErr(influxdb.EInvalid, kindErrFn(err, i, a)) - } - out.SkipResources = append(out.SkipResources, asr) - case ActionTypeSkipKind: - var ask ActionSkipKind - if err := json.Unmarshal(rawAct.Properties, &ask); err != nil { - return actions{}, influxErr(influxdb.EInvalid, unmarshalErrFn(err, i, a)) - } - if err := ask.Kind.OK(); err != nil { - return actions{}, influxErr(influxdb.EInvalid, kindErrFn(err, i, a)) - } - out.SkipKinds = append(out.SkipKinds, ask) - default: - msg := fmt.Sprintf( - "invalid action type %q provided for actions[%d] ; Must be one of [%s]", - a, i, ActionTypeSkipResource, - ) - return actions{}, influxErr(influxdb.EInvalid, msg) - } - } - - return out, nil -} - -// RespApply is the response body for the apply pkg endpoint. -type RespApply struct { - Sources []string `json:"sources" yaml:"sources"` - StackID string `json:"stackID" yaml:"stackID"` - Diff Diff `json:"diff" yaml:"diff"` - Summary Summary `json:"summary" yaml:"summary"` - - Errors []ValidationErr `json:"errors,omitempty" yaml:"errors,omitempty"` -} - func (s *HTTPServerPackages) apply(w http.ResponseWriter, r *http.Request) { var reqBody ReqApply encoding, err := decodeWithEncoding(r, &reqBody) @@ -804,62 +438,6 @@ func (s *HTTPServerPackages) apply(w http.ResponseWriter, r *http.Request) { }) } -type encoder interface { - Encode(interface{}) error -} - -func formatSources(sources []string) string { - return strings.Join(sources, "; ") -} - -func decodeWithEncoding(r *http.Request, v interface{}) (Encoding, error) { - encoding := pkgEncoding(r.Header.Get("Content-Type")) - - var dec interface{ Decode(interface{}) error } - switch encoding { - case EncodingJsonnet: - dec = jsonnet.NewDecoder(r.Body) - case EncodingYAML: - dec = yaml.NewDecoder(r.Body) - default: - dec = json.NewDecoder(r.Body) - } - - return encoding, dec.Decode(v) -} - -func pkgEncoding(contentType string) Encoding { - switch contentType { - case "application/x-jsonnet": - return EncodingJsonnet - case "text/yml", "application/x-yaml": - return EncodingYAML - default: - return EncodingJSON - } -} - -func convertEncoding(ct, rawURL string) Encoding { - ct = strings.ToLower(ct) - urlBase := path.Ext(rawURL) - switch { - case ct == "jsonnet" || urlBase == ".jsonnet": - return EncodingJsonnet - case ct == "json" || urlBase == ".json": - return EncodingJSON - case ct == "yml" || ct == "yaml" || urlBase == ".yml" || urlBase == ".yaml": - return EncodingYAML - default: - return EncodingSource - } -} - -func newJSONEnc(w io.Writer) encoder { - enc := json.NewEncoder(w) - enc.SetIndent("", "\t") - return enc -} - func (s *HTTPServerPackages) encResp(w http.ResponseWriter, r *http.Request, enc encoder, code int, res interface{}) { w.WriteHeader(code) if err := enc.Encode(res); err != nil { @@ -871,22 +449,6 @@ func (s *HTTPServerPackages) encResp(w http.ResponseWriter, r *http.Request, enc } } -func convertParseErr(err error) []ValidationErr { - pErr, ok := err.(ParseError) - if !ok { - return nil - } - return pErr.ValidationErrs() -} - -func newDecodeErr(encoding string, err error) *influxdb.Error { - return &influxdb.Error{ - Msg: fmt.Sprintf("unable to unmarshal %s", encoding), - Code: influxdb.EInvalid, - Err: err, - } -} - func convertStackToRespStack(st Stack) RespStack { resources := make([]RespStackResource, 0, len(st.Resources)) for _, r := range st.Resources { diff --git a/pkger/http_server_stack.go b/pkger/http_server_stack.go new file mode 100644 index 0000000000..e9b27e46b5 --- /dev/null +++ b/pkger/http_server_stack.go @@ -0,0 +1,360 @@ +package pkger + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/go-chi/chi" + "github.com/influxdata/influxdb/v2" + pctx "github.com/influxdata/influxdb/v2/context" + kithttp "github.com/influxdata/influxdb/v2/kit/transport/http" + "go.uber.org/zap" +) + +const RoutePrefixStacks = "/api/v2/stacks" + +// HTTPServerStacks is a server that manages the stacks HTTP transport. +type HTTPServerStacks struct { + chi.Router + api *kithttp.API + logger *zap.Logger + svc SVC +} + +// NewHTTPServerStacks constructs a new http server. +func NewHTTPServerStacks(log *zap.Logger, svc SVC) *HTTPServerStacks { + svr := &HTTPServerStacks{ + api: kithttp.NewAPI(kithttp.WithLog(log)), + logger: log, + svc: svc, + } + + r := chi.NewRouter() + { + r.Post("/", svr.createStack) + r.Get("/", svr.listStacks) + + r.Route("/{stack_id}", func(r chi.Router) { + r.Get("/", svr.readStack) + r.Delete("/", svr.deleteStack) + r.Patch("/", svr.updateStack) + }) + } + + svr.Router = r + return svr +} + +// Prefix provides the prefix to this route tree. +func (s *HTTPServerStacks) Prefix() string { + return RoutePrefixStacks +} + +type ( + // RespStack is the response body for a stack. + RespStack struct { + ID string `json:"id"` + OrgID string `json:"orgID"` + Name string `json:"name"` + Description string `json:"description"` + Resources []RespStackResource `json:"resources"` + Sources []string `json:"sources"` + URLs []string `json:"urls"` + influxdb.CRUDLog + } + + // RespStackResource is the response for a stack resource. This type exists + // to decouple the internal service implementation from the deprecates usage + // of pkgs in the API. We could add a custom UnmarshalJSON method, but + // I would rather keep it obvious and explicit with a separate field. + RespStackResource struct { + APIVersion string `json:"apiVersion"` + ID string `json:"resourceID"` + Kind Kind `json:"kind"` + MetaName string `json:"templateMetaName"` + Associations []RespStackResourceAssoc `json:"associations"` + + // PkgName is deprecated moving forward, will support until it is + // ripped out. + PkgName *string `json:"pkgName,omitempty"` + } + + // RespStackResourceAssoc is the response for a stack resource's associations. + RespStackResourceAssoc struct { + Kind Kind `json:"kind"` + MetaName string `json:"metaName"` + + //PkgName is to be deprecated moving forward + PkgName *string `json:"pkgName,omitempty"` + } +) + +// RespListStacks is the HTTP response for a stack list call. +type RespListStacks struct { + Stacks []RespStack `json:"stacks"` +} + +func (s *HTTPServerStacks) listStacks(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + rawOrgID := q.Get("orgID") + orgID, err := influxdb.IDFromString(rawOrgID) + if err != nil { + s.api.Err(w, r, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("organization id[%q] is invalid", rawOrgID), + Err: err, + }) + return + } + + if err := r.ParseForm(); err != nil { + s.api.Err(w, r, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "failed to parse form from encoded url", + Err: err, + }) + return + } + + filter := ListFilter{ + Names: r.Form["name"], + } + + for _, idRaw := range r.Form["stackID"] { + id, err := influxdb.IDFromString(idRaw) + if err != nil { + s.api.Err(w, r, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("stack ID[%q] provided is invalid", idRaw), + Err: err, + }) + return + } + filter.StackIDs = append(filter.StackIDs, *id) + } + + stacks, err := s.svc.ListStacks(r.Context(), *orgID, filter) + if err != nil { + s.api.Err(w, r, err) + return + } + if stacks == nil { + stacks = []Stack{} + } + + out := make([]RespStack, 0, len(stacks)) + for _, st := range stacks { + out = append(out, convertStackToRespStack(st)) + } + + s.api.Respond(w, r, http.StatusOK, RespListStacks{ + Stacks: out, + }) +} + +// ReqCreateStack is a request body for a create stack call. +type ReqCreateStack struct { + OrgID string `json:"orgID"` + Name string `json:"name"` + Description string `json:"description"` + URLs []string `json:"urls"` +} + +// OK validates the request body is valid. +func (r *ReqCreateStack) OK() error { + // TODO: provide multiple errors back for failing validation + if _, err := influxdb.IDFromString(r.OrgID); err != nil { + return &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("provided org id[%q] is invalid", r.OrgID), + } + } + + for _, u := range r.URLs { + if _, err := url.Parse(u); err != nil { + return &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("provided url[%q] is invalid", u), + } + } + } + return nil +} + +func (r *ReqCreateStack) orgID() influxdb.ID { + orgID, _ := influxdb.IDFromString(r.OrgID) + return *orgID +} + +func (s *HTTPServerStacks) createStack(w http.ResponseWriter, r *http.Request) { + var reqBody ReqCreateStack + if err := s.api.DecodeJSON(r.Body, &reqBody); err != nil { + s.api.Err(w, r, err) + return + } + defer r.Body.Close() + + auth, err := pctx.GetAuthorizer(r.Context()) + if err != nil { + s.api.Err(w, r, err) + return + } + + stack, err := s.svc.InitStack(r.Context(), auth.GetUserID(), Stack{ + OrgID: reqBody.orgID(), + Name: reqBody.Name, + Description: reqBody.Description, + TemplateURLs: reqBody.URLs, + }) + if err != nil { + s.api.Err(w, r, err) + return + } + + s.api.Respond(w, r, http.StatusCreated, convertStackToRespStack(stack)) +} + +func (s *HTTPServerStacks) deleteStack(w http.ResponseWriter, r *http.Request) { + orgID, err := getRequiredOrgIDFromQuery(r.URL.Query()) + if err != nil { + s.api.Err(w, r, err) + return + } + + stackID, err := stackIDFromReq(r) + if err != nil { + s.api.Err(w, r, err) + return + } + + auth, err := pctx.GetAuthorizer(r.Context()) + if err != nil { + s.api.Err(w, r, err) + return + } + userID := auth.GetUserID() + + err = s.svc.DeleteStack(r.Context(), struct{ OrgID, UserID, StackID influxdb.ID }{ + OrgID: orgID, + UserID: userID, + StackID: stackID, + }) + if err != nil { + s.api.Err(w, r, err) + return + } + + s.api.Respond(w, r, http.StatusNoContent, nil) +} + +func (s *HTTPServerStacks) readStack(w http.ResponseWriter, r *http.Request) { + stackID, err := stackIDFromReq(r) + if err != nil { + s.api.Err(w, r, err) + return + } + + stack, err := s.svc.ReadStack(r.Context(), stackID) + if err != nil { + s.api.Err(w, r, err) + return + } + + s.api.Respond(w, r, http.StatusOK, convertStackToRespStack(stack)) +} + +type ( + // ReqUpdateStack is the request body for updating a stack. + ReqUpdateStack struct { + Name *string `json:"name"` + Description *string `json:"description"` + TemplateURLs []string `json:"templateURLs"` + AdditionalResources []ReqUpdateStackResource `json:"additionalResources"` + + // Deprecating the urls field and replacing with templateURLs field. + // This is remaining here for backwards compatibility. + URLs []string `json:"urls"` + } + + ReqUpdateStackResource struct { + ID string `json:"resourceID"` + Kind Kind `json:"kind"` + MetaName string `json:"templateMetaName"` + } +) + +func (s *HTTPServerStacks) updateStack(w http.ResponseWriter, r *http.Request) { + var req ReqUpdateStack + if err := s.api.DecodeJSON(r.Body, &req); err != nil { + s.api.Err(w, r, err) + return + } + + stackID, err := stackIDFromReq(r) + if err != nil { + s.api.Err(w, r, err) + return + } + + update := StackUpdate{ + ID: stackID, + Name: req.Name, + Description: req.Description, + TemplateURLs: append(req.TemplateURLs, req.URLs...), + } + for _, res := range req.AdditionalResources { + id, err := influxdb.IDFromString(res.ID) + if err != nil { + s.api.Err(w, r, influxErr(influxdb.EInvalid, err, fmt.Sprintf("stack resource id %q", res.ID))) + return + } + update.AdditionalResources = append(update.AdditionalResources, StackAdditionalResource{ + APIVersion: APIVersion, + ID: *id, + Kind: res.Kind, + MetaName: res.MetaName, + }) + } + + stack, err := s.svc.UpdateStack(r.Context(), update) + if err != nil { + s.api.Err(w, r, err) + return + } + + s.api.Respond(w, r, http.StatusOK, convertStackToRespStack(stack)) +} + +func stackIDFromReq(r *http.Request) (influxdb.ID, error) { + stackID, err := influxdb.IDFromString(chi.URLParam(r, "stack_id")) + if err != nil { + return 0, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "the stack id provided in the path was invalid", + Err: err, + } + } + return *stackID, nil +} + +func getRequiredOrgIDFromQuery(q url.Values) (influxdb.ID, error) { + orgIDRaw := q.Get("orgID") + if orgIDRaw == "" { + return 0, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "the orgID query param is required", + } + } + + orgID, err := influxdb.IDFromString(orgIDRaw) + if err != nil { + return 0, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: "the orgID query param was invalid", + Err: err, + } + } + return *orgID, nil +} diff --git a/pkger/http_server_stack_test.go b/pkger/http_server_stack_test.go new file mode 100644 index 0000000000..93b9279a06 --- /dev/null +++ b/pkger/http_server_stack_test.go @@ -0,0 +1,596 @@ +package pkger_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/pkg/testttp" + "github.com/influxdata/influxdb/v2/pkger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestPkgerHTTPServerStacks(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadFile(strings.TrimPrefix(r.URL.Path, "/")) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Write(b) + }) + filesvr := httptest.NewServer(mux) + defer filesvr.Close() + + strPtr := func(s string) *string { + return &s + } + + t.Run("create a stack", func(t *testing.T) { + t.Run("should successfully return with valid req body", func(t *testing.T) { + svc := &fakeSVC{ + initStackFn: func(ctx context.Context, userID influxdb.ID, stack pkger.Stack) (pkger.Stack, error) { + stack.ID = 3 + stack.CreatedAt = time.Now() + stack.UpdatedAt = time.Now() + return stack, nil + }, + } + pkgHandler := pkger.NewHTTPServerStacks(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + reqBody := pkger.ReqCreateStack{ + OrgID: influxdb.ID(3).String(), + Name: "threeve", + Description: "desc", + URLs: []string{"http://example.com"}, + } + + testttp. + PostJSON(t, "/api/v2/stacks", reqBody). + Headers("Content-Type", "application/json"). + Do(svr). + ExpectStatus(http.StatusCreated). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespStack + decodeBody(t, buf, &resp) + + assert.NotZero(t, resp.ID) + assert.Equal(t, reqBody.OrgID, resp.OrgID) + assert.Equal(t, reqBody.Name, resp.Name) + assert.Equal(t, reqBody.Description, resp.Description) + assert.Equal(t, reqBody.URLs, resp.URLs) + assert.NotZero(t, resp.CRUDLog) + }) + + }) + + t.Run("error cases", func(t *testing.T) { + tests := []struct { + name string + reqBody pkger.ReqCreateStack + expectedStatus int + svc pkger.SVC + }{ + { + name: "bad org id", + reqBody: pkger.ReqCreateStack{ + OrgID: "invalid id", + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "bad url", + reqBody: pkger.ReqCreateStack{ + OrgID: influxdb.ID(3).String(), + URLs: []string{"invalid @% url"}, + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "translates svc conflict error", + reqBody: pkger.ReqCreateStack{OrgID: influxdb.ID(3).String()}, + svc: &fakeSVC{ + initStackFn: func(ctx context.Context, userID influxdb.ID, stack pkger.Stack) (pkger.Stack, error) { + return pkger.Stack{}, &influxdb.Error{Code: influxdb.EConflict} + }, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "translates svc internal error", + reqBody: pkger.ReqCreateStack{OrgID: influxdb.ID(3).String()}, + svc: &fakeSVC{ + initStackFn: func(ctx context.Context, userID influxdb.ID, stack pkger.Stack) (pkger.Stack, error) { + return pkger.Stack{}, &influxdb.Error{Code: influxdb.EInternal} + }, + }, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := tt.svc + if svc == nil { + svc = &fakeSVC{ + initStackFn: func(ctx context.Context, userID influxdb.ID, stack pkger.Stack) (pkger.Stack, error) { + return stack, nil + }, + } + } + + pkgHandler := pkger.NewHTTPServerStacks(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PostJSON(t, "/api/v2/stacks", tt.reqBody). + Headers("Content-Type", "application/json"). + Do(svr). + ExpectStatus(tt.expectedStatus) + } + + t.Run(tt.name, fn) + } + }) + }) + + t.Run("list a stack", func(t *testing.T) { + t.Run("should successfully return with valid req body", func(t *testing.T) { + const expectedOrgID influxdb.ID = 3 + + svc := &fakeSVC{ + listStacksFn: func(ctx context.Context, orgID influxdb.ID, filter pkger.ListFilter) ([]pkger.Stack, error) { + if orgID != expectedOrgID { + return nil, nil + } + + if len(filter.Names) > 0 && len(filter.StackIDs) == 0 { + var stacks []pkger.Stack + for i, name := range filter.Names { + stacks = append(stacks, pkger.Stack{ + ID: influxdb.ID(i + 1), + OrgID: expectedOrgID, + Name: name, + }) + } + return stacks, nil + } + + if len(filter.StackIDs) > 0 && len(filter.Names) == 0 { + var stacks []pkger.Stack + for _, stackID := range filter.StackIDs { + stacks = append(stacks, pkger.Stack{ + ID: stackID, + OrgID: expectedOrgID, + }) + } + return stacks, nil + } + + return []pkger.Stack{{ + ID: 1, + OrgID: expectedOrgID, + Name: "stack_1", + }}, nil + }, + } + pkgHandler := pkger.NewHTTPServerStacks(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + tests := []struct { + name string + queryArgs string + expectedStacks []pkger.RespStack + }{ + { + name: "with org ID that has stacks", + queryArgs: "orgID=" + expectedOrgID.String(), + expectedStacks: []pkger.RespStack{{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Name: "stack_1", + Resources: []pkger.RespStackResource{}, + Sources: []string{}, + URLs: []string{}, + }}, + }, + { + name: "with orgID with no stacks", + queryArgs: "orgID=" + influxdb.ID(9000).String(), + expectedStacks: []pkger.RespStack{}, + }, + { + name: "with names", + queryArgs: "name=name_stack&name=threeve&orgID=" + influxdb.ID(expectedOrgID).String(), + expectedStacks: []pkger.RespStack{ + { + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Name: "name_stack", + Resources: []pkger.RespStackResource{}, + Sources: []string{}, + URLs: []string{}, + }, + { + ID: influxdb.ID(2).String(), + OrgID: expectedOrgID.String(), + Name: "threeve", + Resources: []pkger.RespStackResource{}, + Sources: []string{}, + URLs: []string{}, + }, + }, + }, + { + name: "with ids", + queryArgs: fmt.Sprintf("stackID=%s&stackID=%s&orgID=%s", influxdb.ID(1), influxdb.ID(2), influxdb.ID(expectedOrgID)), + expectedStacks: []pkger.RespStack{ + { + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Resources: []pkger.RespStackResource{}, + Sources: []string{}, + URLs: []string{}, + }, + { + ID: influxdb.ID(2).String(), + OrgID: expectedOrgID.String(), + Resources: []pkger.RespStackResource{}, + Sources: []string{}, + URLs: []string{}, + }, + }, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + testttp. + Get(t, "/api/v2/stacks?"+tt.queryArgs). + Headers("Content-Type", "application/x-www-form-urlencoded"). + Do(svr). + ExpectStatus(http.StatusOK). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespListStacks + decodeBody(t, buf, &resp) + + assert.Equal(t, tt.expectedStacks, resp.Stacks) + }) + } + + t.Run(tt.name, fn) + } + }) + }) + + t.Run("read a stack", func(t *testing.T) { + t.Run("should successfully return with valid req body", func(t *testing.T) { + const expectedOrgID influxdb.ID = 3 + + tests := []struct { + name string + stub pkger.Stack + expectedStack pkger.RespStack + }{ + { + name: "for stack that has all fields available", + stub: pkger.Stack{ + ID: 1, + OrgID: expectedOrgID, + Name: "name", + Description: "desc", + Sources: []string{"threeve"}, + TemplateURLs: []string{"http://example.com"}, + Resources: []pkger.StackResource{ + { + APIVersion: pkger.APIVersion, + ID: 3, + Kind: pkger.KindBucket, + MetaName: "rucketeer", + }, + }, + }, + expectedStack: pkger.RespStack{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Name: "name", + Description: "desc", + Sources: []string{"threeve"}, + URLs: []string{"http://example.com"}, + Resources: []pkger.RespStackResource{ + { + APIVersion: pkger.APIVersion, + ID: influxdb.ID(3).String(), + Kind: pkger.KindBucket, + MetaName: "rucketeer", + Associations: []pkger.RespStackResourceAssoc{}, + }, + }, + }, + }, + { + name: "for stack that has missing resources urls and sources", + stub: pkger.Stack{ + ID: 1, + OrgID: expectedOrgID, + Name: "name", + Description: "desc", + }, + expectedStack: pkger.RespStack{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Name: "name", + Description: "desc", + Sources: []string{}, + URLs: []string{}, + Resources: []pkger.RespStackResource{}, + }, + }, + { + name: "for stack that has no set fields", + stub: pkger.Stack{ + ID: 1, + OrgID: expectedOrgID, + }, + expectedStack: pkger.RespStack{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Sources: []string{}, + URLs: []string{}, + Resources: []pkger.RespStackResource{}, + }, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := &fakeSVC{ + readStackFn: func(ctx context.Context, id influxdb.ID) (pkger.Stack, error) { + return tt.stub, nil + }, + } + pkgHandler := pkger.NewHTTPServerStacks(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + Get(t, "/api/v2/stacks/"+tt.stub.ID.String()). + Do(svr). + ExpectStatus(http.StatusOK). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespStack + decodeBody(t, buf, &resp) + + assert.Equal(t, tt.expectedStack, resp) + }) + } + + t.Run(tt.name, fn) + } + }) + + t.Run("error cases", func(t *testing.T) { + tests := []struct { + name string + stackIDPath string + expectedStatus int + svc pkger.SVC + }{ + { + name: "bad stack id path", + stackIDPath: "badID", + expectedStatus: http.StatusBadRequest, + }, + { + name: "stack not found", + stackIDPath: influxdb.ID(1).String(), + svc: &fakeSVC{ + readStackFn: func(ctx context.Context, id influxdb.ID) (pkger.Stack, error) { + return pkger.Stack{}, &influxdb.Error{Code: influxdb.ENotFound} + }, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := tt.svc + if svc == nil { + svc = &fakeSVC{ + initStackFn: func(ctx context.Context, userID influxdb.ID, stack pkger.Stack) (pkger.Stack, error) { + return stack, nil + }, + } + } + + pkgHandler := pkger.NewHTTPServerStacks(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + Get(t, "/api/v2/stacks/"+tt.stackIDPath). + Headers("Content-Type", "application/json"). + Do(svr). + ExpectStatus(tt.expectedStatus) + } + + t.Run(tt.name, fn) + } + }) + }) + + t.Run("update a stack", func(t *testing.T) { + t.Run("should successfully update with valid req body", func(t *testing.T) { + const expectedOrgID influxdb.ID = 3 + + tests := []struct { + name string + input pkger.ReqUpdateStack + expectedStack pkger.RespStack + }{ + { + name: "update name field", + input: pkger.ReqUpdateStack{ + Name: strPtr("name"), + }, + expectedStack: pkger.RespStack{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Name: "name", + Sources: []string{}, + URLs: []string{}, + Resources: []pkger.RespStackResource{}, + }, + }, + { + name: "update desc field", + input: pkger.ReqUpdateStack{ + Description: strPtr("desc"), + }, + expectedStack: pkger.RespStack{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Description: "desc", + Sources: []string{}, + URLs: []string{}, + Resources: []pkger.RespStackResource{}, + }, + }, + { + name: "update urls field", + input: pkger.ReqUpdateStack{ + TemplateURLs: []string{"http://example.com"}, + }, + expectedStack: pkger.RespStack{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Sources: []string{}, + URLs: []string{"http://example.com"}, + Resources: []pkger.RespStackResource{}, + }, + }, + { + name: "update all fields", + input: pkger.ReqUpdateStack{ + Name: strPtr("name"), + Description: strPtr("desc"), + TemplateURLs: []string{"http://example.com"}, + }, + expectedStack: pkger.RespStack{ + ID: influxdb.ID(1).String(), + OrgID: expectedOrgID.String(), + Name: "name", + Description: "desc", + Sources: []string{}, + URLs: []string{"http://example.com"}, + Resources: []pkger.RespStackResource{}, + }, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + id, err := influxdb.IDFromString(tt.expectedStack.ID) + require.NoError(t, err) + + svc := &fakeSVC{ + updateStackFn: func(ctx context.Context, upd pkger.StackUpdate) (pkger.Stack, error) { + if upd.ID != *id { + return pkger.Stack{}, errors.New("unexpected stack ID: " + upd.ID.String()) + } + st := pkger.Stack{ + ID: *id, + OrgID: expectedOrgID, + } + if upd.Name != nil { + st.Name = *upd.Name + } + if upd.Description != nil { + st.Description = *upd.Description + } + if upd.TemplateURLs != nil { + st.TemplateURLs = upd.TemplateURLs + } + return st, nil + }, + } + pkgHandler := pkger.NewHTTPServerStacks(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PatchJSON(t, "/api/v2/stacks/"+tt.expectedStack.ID, tt.input). + Do(svr). + ExpectStatus(http.StatusOK). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespStack + decodeBody(t, buf, &resp) + + assert.Equal(t, tt.expectedStack, resp) + }) + } + + t.Run(tt.name, fn) + } + }) + + t.Run("error cases", func(t *testing.T) { + tests := []struct { + name string + stackIDPath string + expectedStatus int + svc pkger.SVC + }{ + { + name: "bad stack id path", + stackIDPath: "badID", + expectedStatus: http.StatusBadRequest, + }, + { + name: "stack not found", + stackIDPath: influxdb.ID(1).String(), + svc: &fakeSVC{ + readStackFn: func(ctx context.Context, id influxdb.ID) (pkger.Stack, error) { + return pkger.Stack{}, &influxdb.Error{Code: influxdb.ENotFound} + }, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := tt.svc + if svc == nil { + svc = &fakeSVC{ + initStackFn: func(ctx context.Context, userID influxdb.ID, stack pkger.Stack) (pkger.Stack, error) { + return stack, nil + }, + } + } + + pkgHandler := pkger.NewHTTPServerStacks(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + Get(t, "/api/v2/stacks/"+tt.stackIDPath). + Headers("Content-Type", "application/json"). + Do(svr). + ExpectStatus(tt.expectedStatus) + } + + t.Run(tt.name, fn) + } + }) + }) +} diff --git a/pkger/http_server_template.go b/pkger/http_server_template.go new file mode 100644 index 0000000000..8cbd97986f --- /dev/null +++ b/pkger/http_server_template.go @@ -0,0 +1,529 @@ +package pkger + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "path" + "strings" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/influxdata/influxdb/v2" + pctx "github.com/influxdata/influxdb/v2/context" + ierrors "github.com/influxdata/influxdb/v2/kit/errors" + kithttp "github.com/influxdata/influxdb/v2/kit/transport/http" + "github.com/influxdata/influxdb/v2/pkg/jsonnet" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +const RoutePrefixTemplates = "/api/v2/templates" + +// HTTPServerTemplates is a server that manages the templates HTTP transport. +type HTTPServerTemplates struct { + chi.Router + api *kithttp.API + logger *zap.Logger + svc SVC +} + +// NewHTTPServerTemplates constructs a new http server. +func NewHTTPServerTemplates(log *zap.Logger, svc SVC) *HTTPServerTemplates { + svr := &HTTPServerTemplates{ + api: kithttp.NewAPI(kithttp.WithLog(log)), + logger: log, + svc: svc, + } + + exportAllowContentTypes := middleware.AllowContentType("text/yml", "application/x-yaml", "application/json") + setJSONContentType := middleware.SetHeader("Content-Type", "application/json; charset=utf-8") + + r := chi.NewRouter() + { + r.With(exportAllowContentTypes).Post("/export", svr.export) + r.With(setJSONContentType).Post("/apply", svr.apply) + } + + svr.Router = r + return svr +} + +// Prefix provides the prefix to this route tree. +func (s *HTTPServerTemplates) Prefix() string { + return RoutePrefixTemplates +} + +// ReqExportOrgIDOpt provides options to export resources by organization id. +type ReqExportOrgIDOpt struct { + OrgID string `json:"orgID"` + Filters struct { + ByLabel []string `json:"byLabel"` + ByResourceKind []Kind `json:"byResourceKind"` + } `json:"resourceFilters"` +} + +// ReqExport is a request body for the export endpoint. +type ReqExport struct { + StackID string `json:"stackID"` + OrgIDs []ReqExportOrgIDOpt `json:"orgIDs"` + Resources []ResourceToClone `json:"resources"` +} + +// OK validates a create request. +func (r *ReqExport) OK() error { + if len(r.Resources) == 0 && len(r.OrgIDs) == 0 && r.StackID == "" { + return &influxdb.Error{ + Code: influxdb.EUnprocessableEntity, + Msg: "at least 1 resource, 1 org id, or stack id must be provided", + } + } + + for _, org := range r.OrgIDs { + if _, err := influxdb.IDFromString(org.OrgID); err != nil { + return &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("provided org id is invalid: %q", org.OrgID), + } + } + } + + if r.StackID != "" { + _, err := influxdb.IDFromString(r.StackID) + return err + } + return nil +} + +// RespExport is a response body for the create pkg endpoint. +type RespExport []Object + +func (s *HTTPServerTemplates) export(w http.ResponseWriter, r *http.Request) { + var reqBody ReqExport + if err := s.api.DecodeJSON(r.Body, &reqBody); err != nil { + s.api.Err(w, r, err) + return + } + defer r.Body.Close() + + opts := []ExportOptFn{ + ExportWithExistingResources(reqBody.Resources...), + } + for _, orgIDStr := range reqBody.OrgIDs { + orgID, err := influxdb.IDFromString(orgIDStr.OrgID) + if err != nil { + continue + } + opts = append(opts, ExportWithAllOrgResources(ExportByOrgIDOpt{ + OrgID: *orgID, + LabelNames: orgIDStr.Filters.ByLabel, + ResourceKinds: orgIDStr.Filters.ByResourceKind, + })) + } + + if reqBody.StackID != "" { + stackID, err := influxdb.IDFromString(reqBody.StackID) + if err != nil { + s.api.Err(w, r, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("invalid stack ID provided: %q", reqBody.StackID), + }) + return + } + opts = append(opts, ExportWithStackID(*stackID)) + } + + newPkg, err := s.svc.Export(r.Context(), opts...) + if err != nil { + s.api.Err(w, r, err) + return + } + + resp := RespExport(newPkg.Objects) + if resp == nil { + resp = []Object{} + } + + var enc encoder + switch pkgEncoding(r.Header.Get("Accept")) { + case EncodingYAML: + enc = yaml.NewEncoder(w) + w.Header().Set("Content-Type", "application/x-yaml") + default: + enc = newJSONEnc(w) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + } + + s.encResp(w, r, enc, http.StatusOK, resp) +} + +// ReqTemplateRemote provides a package via a remote (i.e. a gist). If content type is not +// provided then the service will do its best to discern the content type of the +// contents. +type ReqTemplateRemote struct { + URL string `json:"url" yaml:"url"` + ContentType string `json:"contentType" yaml:"contentType"` +} + +// Encoding returns the encoding type that corresponds to the given content type. +func (p ReqTemplateRemote) Encoding() Encoding { + return convertEncoding(p.ContentType, p.URL) +} + +type ReqRawTemplate struct { + ContentType string `json:"contentType" yaml:"contentType"` + Sources []string `json:"sources" yaml:"sources"` + Pkg json.RawMessage `json:"contents" yaml:"contents"` +} + +func (p ReqRawTemplate) Encoding() Encoding { + var source string + if len(p.Sources) > 0 { + source = p.Sources[0] + } + return convertEncoding(p.ContentType, source) +} + +// ReqRawAction is a raw action consumers can provide to change the behavior +// of the application of a template. +type ReqRawAction struct { + Action string `json:"action"` + Properties json.RawMessage `json:"properties"` +} + +// ReqApply is the request body for a json or yaml body for the apply pkg endpoint. +type ReqApply struct { + DryRun bool `json:"dryRun" yaml:"dryRun"` + OrgID string `json:"orgID" yaml:"orgID"` + StackID *string `json:"stackID" yaml:"stackID"` // optional: non nil value signals stack should be used + Remotes []ReqTemplateRemote `json:"remotes" yaml:"remotes"` + + // TODO(jsteenb2): pkg references will all be replaced by template references + // these 2 exist alongside the templates for backwards compatibility + // until beta13 rolls out the door. This code should get axed when the next + // OSS release goes out. + RawPkgs []json.RawMessage `json:"packages" yaml:"packages"` + RawPkg json.RawMessage `json:"package" yaml:"package"` + + RawTemplates []ReqRawTemplate `json:"templates" yaml:"templates"` + RawTemplate ReqRawTemplate `json:"template" yaml:"template"` + + EnvRefs map[string]string `json:"envRefs"` + Secrets map[string]string `json:"secrets"` + + RawActions []ReqRawAction `json:"actions"` +} + +// Pkgs returns all pkgs associated with the request. +func (r ReqApply) Pkgs(encoding Encoding) (*Pkg, error) { + var rawPkgs []*Pkg + for _, rem := range r.Remotes { + if rem.URL == "" { + continue + } + pkg, err := Parse(rem.Encoding(), FromHTTPRequest(rem.URL), ValidSkipParseError()) + if err != nil { + return nil, &influxdb.Error{ + Code: influxdb.EUnprocessableEntity, + Msg: fmt.Sprintf("pkg from url[%s] had an issue: %s", rem.URL, err.Error()), + } + } + rawPkgs = append(rawPkgs, pkg) + } + + for i, rawPkg := range append(r.RawPkgs, r.RawPkg) { + if rawPkg == nil { + continue + } + + pkg, err := Parse(encoding, FromReader(bytes.NewReader(rawPkg)), ValidSkipParseError()) + if err != nil { + return nil, &influxdb.Error{ + Code: influxdb.EUnprocessableEntity, + Msg: fmt.Sprintf("pkg[%d] had an issue: %s", i, err.Error()), + } + } + rawPkgs = append(rawPkgs, pkg) + } + + for i, rawTmpl := range append(r.RawTemplates, r.RawTemplate) { + if rawTmpl.Pkg == nil { + continue + } + enc := encoding + if sourceEncoding := rawTmpl.Encoding(); sourceEncoding != EncodingSource { + enc = sourceEncoding + } + pkg, err := Parse(enc, FromReader(bytes.NewReader(rawTmpl.Pkg), rawTmpl.Sources...), ValidSkipParseError()) + if err != nil { + sources := formatSources(rawTmpl.Sources) + return nil, &influxdb.Error{ + Code: influxdb.EUnprocessableEntity, + Msg: fmt.Sprintf("pkg[%d] from source(s) %q had an issue: %s", i, sources, err.Error()), + } + } + rawPkgs = append(rawPkgs, pkg) + } + + return Combine(rawPkgs, ValidWithoutResources(), ValidSkipParseError()) +} + +type actionType string + +// various ActionTypes the transport API speaks +const ( + ActionTypeSkipKind actionType = "skipKind" + ActionTypeSkipResource actionType = "skipResource" +) + +func (r ReqApply) validActions() (struct { + SkipKinds []ActionSkipKind + SkipResources []ActionSkipResource +}, error) { + type actions struct { + SkipKinds []ActionSkipKind + SkipResources []ActionSkipResource + } + + unmarshalErrFn := func(err error, idx int, actionType string) error { + msg := fmt.Sprintf("failed to unmarshal properties for actions[%d] %q", idx, actionType) + return ierrors.Wrap(err, msg) + } + + kindErrFn := func(err error, idx int, actionType string) error { + msg := fmt.Sprintf("invalid kind for actions[%d] %q", idx, actionType) + return ierrors.Wrap(err, msg) + } + + var out actions + for i, rawAct := range r.RawActions { + switch a := rawAct.Action; actionType(a) { + case ActionTypeSkipResource: + var asr ActionSkipResource + if err := json.Unmarshal(rawAct.Properties, &asr); err != nil { + return actions{}, influxErr(influxdb.EInvalid, unmarshalErrFn(err, i, a)) + } + if err := asr.Kind.OK(); err != nil { + return actions{}, influxErr(influxdb.EInvalid, kindErrFn(err, i, a)) + } + out.SkipResources = append(out.SkipResources, asr) + case ActionTypeSkipKind: + var ask ActionSkipKind + if err := json.Unmarshal(rawAct.Properties, &ask); err != nil { + return actions{}, influxErr(influxdb.EInvalid, unmarshalErrFn(err, i, a)) + } + if err := ask.Kind.OK(); err != nil { + return actions{}, influxErr(influxdb.EInvalid, kindErrFn(err, i, a)) + } + out.SkipKinds = append(out.SkipKinds, ask) + default: + msg := fmt.Sprintf( + "invalid action type %q provided for actions[%d] ; Must be one of [%s]", + a, i, ActionTypeSkipResource, + ) + return actions{}, influxErr(influxdb.EInvalid, msg) + } + } + + return out, nil +} + +// RespApply is the response body for the apply pkg endpoint. +type RespApply struct { + Sources []string `json:"sources" yaml:"sources"` + StackID string `json:"stackID" yaml:"stackID"` + Diff Diff `json:"diff" yaml:"diff"` + Summary Summary `json:"summary" yaml:"summary"` + + Errors []ValidationErr `json:"errors,omitempty" yaml:"errors,omitempty"` +} + +func (s *HTTPServerTemplates) apply(w http.ResponseWriter, r *http.Request) { + var reqBody ReqApply + encoding, err := decodeWithEncoding(r, &reqBody) + if err != nil { + s.api.Err(w, r, newDecodeErr(encoding.String(), err)) + return + } + + orgID, err := influxdb.IDFromString(reqBody.OrgID) + if err != nil { + s.api.Err(w, r, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("invalid organization ID provided: %q", reqBody.OrgID), + }) + return + } + + var stackID influxdb.ID + if reqBody.StackID != nil { + if err := stackID.DecodeFromString(*reqBody.StackID); err != nil { + s.api.Err(w, r, &influxdb.Error{ + Code: influxdb.EInvalid, + Msg: fmt.Sprintf("invalid stack ID provided: %q", *reqBody.StackID), + }) + return + } + } + + parsedPkg, err := reqBody.Pkgs(encoding) + if err != nil { + s.api.Err(w, r, &influxdb.Error{ + Code: influxdb.EUnprocessableEntity, + Err: err, + }) + return + } + + actions, err := reqBody.validActions() + if err != nil { + s.api.Err(w, r, err) + return + } + + applyOpts := []ApplyOptFn{ + ApplyWithEnvRefs(reqBody.EnvRefs), + ApplyWithPkg(parsedPkg), + ApplyWithStackID(stackID), + } + for _, a := range actions.SkipResources { + applyOpts = append(applyOpts, ApplyWithResourceSkip(a)) + } + for _, a := range actions.SkipKinds { + applyOpts = append(applyOpts, ApplyWithKindSkip(a)) + } + + auth, err := pctx.GetAuthorizer(r.Context()) + if err != nil { + s.api.Err(w, r, err) + return + } + userID := auth.GetUserID() + + if reqBody.DryRun { + impact, err := s.svc.DryRun(r.Context(), *orgID, userID, applyOpts...) + if IsParseErr(err) { + s.api.Respond(w, r, http.StatusUnprocessableEntity, RespApply{ + Sources: append([]string{}, impact.Sources...), // guarantee non nil slice + StackID: impact.StackID.String(), + Diff: impact.Diff, + Summary: impact.Summary, + Errors: convertParseErr(err), + }) + return + } + if err != nil { + s.api.Err(w, r, err) + return + } + + s.api.Respond(w, r, http.StatusOK, RespApply{ + Sources: append([]string{}, impact.Sources...), // guarantee non nil slice + StackID: impact.StackID.String(), + Diff: impact.Diff, + Summary: impact.Summary, + }) + return + } + + applyOpts = append(applyOpts, ApplyWithSecrets(reqBody.Secrets)) + + impact, err := s.svc.Apply(r.Context(), *orgID, userID, applyOpts...) + if err != nil && !IsParseErr(err) { + s.api.Err(w, r, err) + return + } + + s.api.Respond(w, r, http.StatusCreated, RespApply{ + Sources: append([]string{}, impact.Sources...), // guarantee non nil slice + StackID: impact.StackID.String(), + Diff: impact.Diff, + Summary: impact.Summary, + Errors: convertParseErr(err), + }) +} + +func (s *HTTPServerTemplates) encResp(w http.ResponseWriter, r *http.Request, enc encoder, code int, res interface{}) { + w.WriteHeader(code) + if err := enc.Encode(res); err != nil { + s.api.Err(w, r, &influxdb.Error{ + Msg: fmt.Sprintf("unable to marshal; Err: %v", err), + Code: influxdb.EInternal, + Err: err, + }) + } +} + +type encoder interface { + Encode(interface{}) error +} + +func formatSources(sources []string) string { + return strings.Join(sources, "; ") +} + +func decodeWithEncoding(r *http.Request, v interface{}) (Encoding, error) { + encoding := pkgEncoding(r.Header.Get("Content-Type")) + + var dec interface{ Decode(interface{}) error } + switch encoding { + case EncodingJsonnet: + dec = jsonnet.NewDecoder(r.Body) + case EncodingYAML: + dec = yaml.NewDecoder(r.Body) + default: + dec = json.NewDecoder(r.Body) + } + + return encoding, dec.Decode(v) +} + +func pkgEncoding(contentType string) Encoding { + switch contentType { + case "application/x-jsonnet": + return EncodingJsonnet + case "text/yml", "application/x-yaml": + return EncodingYAML + default: + return EncodingJSON + } +} + +func convertEncoding(ct, rawURL string) Encoding { + ct = strings.ToLower(ct) + urlBase := path.Ext(rawURL) + switch { + case ct == "jsonnet" || urlBase == ".jsonnet": + return EncodingJsonnet + case ct == "json" || urlBase == ".json": + return EncodingJSON + case ct == "yml" || ct == "yaml" || urlBase == ".yml" || urlBase == ".yaml": + return EncodingYAML + default: + return EncodingSource + } +} + +func newJSONEnc(w io.Writer) encoder { + enc := json.NewEncoder(w) + enc.SetIndent("", "\t") + return enc +} + +func convertParseErr(err error) []ValidationErr { + pErr, ok := err.(ParseError) + if !ok { + return nil + } + return pErr.ValidationErrs() +} + +func newDecodeErr(encoding string, err error) *influxdb.Error { + return &influxdb.Error{ + Msg: fmt.Sprintf("unable to unmarshal %s", encoding), + Code: influxdb.EInvalid, + Err: err, + } +} diff --git a/pkger/http_server_template_test.go b/pkger/http_server_template_test.go new file mode 100644 index 0000000000..3ab5e78da9 --- /dev/null +++ b/pkger/http_server_template_test.go @@ -0,0 +1,507 @@ +package pkger_test + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "path" + "strings" + "testing" + + "github.com/influxdata/influxdb/v2" + "github.com/influxdata/influxdb/v2/mock" + "github.com/influxdata/influxdb/v2/pkg/testttp" + "github.com/influxdata/influxdb/v2/pkger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestPkgerHTTPServerTemplate(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadFile(strings.TrimPrefix(r.URL.Path, "/")) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Write(b) + }) + filesvr := httptest.NewServer(mux) + defer filesvr.Close() + + newPkgURL := func(t *testing.T, svrURL string, pkgPath string) string { + t.Helper() + + u, err := url.Parse(svrURL) + require.NoError(t, err) + u.Path = path.Join(u.Path, pkgPath) + return u.String() + } + + strPtr := func(s string) *string { + return &s + } + + t.Run("create pkg", func(t *testing.T) { + t.Run("should successfully return with valid req body", func(t *testing.T) { + fakeLabelSVC := mock.NewLabelService() + fakeLabelSVC.FindLabelByIDFn = func(ctx context.Context, id influxdb.ID) (*influxdb.Label, error) { + return &influxdb.Label{ + ID: id, + }, nil + } + svc := pkger.NewService(pkger.WithLabelSVC(fakeLabelSVC)) + pkgHandler := pkger.NewHTTPServerTemplates(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PostJSON(t, "/api/v2/templates/export", pkger.ReqExport{ + Resources: []pkger.ResourceToClone{ + { + Kind: pkger.KindLabel, + ID: 1, + Name: "new name", + }, + }, + }). + Headers("Content-Type", "application/json"). + Do(svr). + ExpectStatus(http.StatusOK). + ExpectBody(func(buf *bytes.Buffer) { + pkg, err := pkger.Parse(pkger.EncodingJSON, pkger.FromReader(buf)) + require.NoError(t, err) + + require.NotNil(t, pkg) + require.NoError(t, pkg.Validate()) + + assert.Len(t, pkg.Objects, 1) + assert.Len(t, pkg.Summary().Labels, 1) + }) + + }) + + t.Run("should be invalid if not org ids or resources provided", func(t *testing.T) { + pkgHandler := pkger.NewHTTPServerTemplates(zap.NewNop(), nil) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PostJSON(t, "/api/v2/templates/export", pkger.ReqExport{}). + Headers("Content-Type", "application/json"). + Do(svr). + ExpectStatus(http.StatusUnprocessableEntity) + + }) + }) + + t.Run("dry run pkg", func(t *testing.T) { + t.Run("json", func(t *testing.T) { + tests := []struct { + name string + contentType string + reqBody pkger.ReqApply + }{ + { + name: "app json", + contentType: "application/json", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: influxdb.ID(9000).String(), + RawTemplate: bucketPkgKinds(t, pkger.EncodingJSON), + }, + }, + { + name: "defaults json when no content type", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: influxdb.ID(9000).String(), + RawTemplate: bucketPkgKinds(t, pkger.EncodingJSON), + }, + }, + { + name: "retrieves package from a URL", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: influxdb.ID(9000).String(), + Remotes: []pkger.ReqTemplateRemote{{ + URL: newPkgURL(t, filesvr.URL, "testdata/remote_bucket.json"), + }}, + }, + }, + { + name: "app jsonnet", + contentType: "application/x-jsonnet", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: influxdb.ID(9000).String(), + RawTemplate: bucketPkgKinds(t, pkger.EncodingJsonnet), + }, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := &fakeSVC{ + dryRunFn: func(ctx context.Context, orgID, userID influxdb.ID, opts ...pkger.ApplyOptFn) (pkger.ImpactSummary, error) { + var opt pkger.ApplyOpt + for _, o := range opts { + o(&opt) + } + pkg, err := pkger.Combine(opt.Pkgs) + if err != nil { + return pkger.ImpactSummary{}, err + } + + if err := pkg.Validate(); err != nil { + return pkger.ImpactSummary{}, err + } + sum := pkg.Summary() + var diff pkger.Diff + for _, b := range sum.Buckets { + diff.Buckets = append(diff.Buckets, pkger.DiffBucket{ + DiffIdentifier: pkger.DiffIdentifier{ + PkgName: b.Name, + }, + }) + } + return pkger.ImpactSummary{ + Summary: sum, + Diff: diff, + }, nil + }, + } + + pkgHandler := pkger.NewHTTPServerTemplates(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PostJSON(t, "/api/v2/templates/apply", tt.reqBody). + Headers("Content-Type", tt.contentType). + Do(svr). + ExpectStatus(http.StatusOK). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespApply + decodeBody(t, buf, &resp) + + assert.Len(t, resp.Summary.Buckets, 1) + assert.Len(t, resp.Diff.Buckets, 1) + }) + } + + t.Run(tt.name, fn) + } + }) + + t.Run("yml", func(t *testing.T) { + tests := []struct { + name string + contentType string + }{ + { + name: "app yml", + contentType: "application/x-yaml", + }, + { + name: "text yml", + contentType: "text/yml", + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := &fakeSVC{ + dryRunFn: func(ctx context.Context, orgID, userID influxdb.ID, opts ...pkger.ApplyOptFn) (pkger.ImpactSummary, error) { + var opt pkger.ApplyOpt + for _, o := range opts { + o(&opt) + } + pkg, err := pkger.Combine(opt.Pkgs) + if err != nil { + return pkger.ImpactSummary{}, err + } + + if err := pkg.Validate(); err != nil { + return pkger.ImpactSummary{}, err + } + sum := pkg.Summary() + var diff pkger.Diff + for _, b := range sum.Buckets { + diff.Buckets = append(diff.Buckets, pkger.DiffBucket{ + DiffIdentifier: pkger.DiffIdentifier{ + PkgName: b.Name, + }, + }) + } + return pkger.ImpactSummary{ + Diff: diff, + Summary: sum, + }, nil + }, + } + + pkgHandler := pkger.NewHTTPServerTemplates(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + body := newReqApplyYMLBody(t, influxdb.ID(9000), true) + + testttp. + Post(t, "/api/v2/templates/apply", body). + Headers("Content-Type", tt.contentType). + Do(svr). + ExpectStatus(http.StatusOK). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespApply + decodeBody(t, buf, &resp) + + assert.Len(t, resp.Summary.Buckets, 1) + assert.Len(t, resp.Diff.Buckets, 1) + }) + } + + t.Run(tt.name, fn) + } + }) + + t.Run("with multiple pkgs", func(t *testing.T) { + newBktPkg := func(t *testing.T, bktName string) pkger.ReqRawTemplate { + t.Helper() + + pkgStr := fmt.Sprintf(`[ + { + "apiVersion": "%[1]s", + "kind": "Bucket", + "metadata": { + "name": %q + }, + "spec": {} + } +]`, pkger.APIVersion, bktName) + + pkg, err := pkger.Parse(pkger.EncodingJSON, pkger.FromString(pkgStr)) + require.NoError(t, err) + + pkgBytes, err := pkg.Encode(pkger.EncodingJSON) + require.NoError(t, err) + return pkger.ReqRawTemplate{ + ContentType: pkger.EncodingJSON.String(), + Sources: pkg.Sources(), + Pkg: pkgBytes, + } + } + + tests := []struct { + name string + reqBody pkger.ReqApply + expectedBkts []string + }{ + { + name: "retrieves package from a URL and raw pkgs", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: influxdb.ID(9000).String(), + Remotes: []pkger.ReqTemplateRemote{{ + ContentType: "json", + URL: newPkgURL(t, filesvr.URL, "testdata/remote_bucket.json"), + }}, + RawTemplates: []pkger.ReqRawTemplate{ + newBktPkg(t, "bkt1"), + newBktPkg(t, "bkt2"), + newBktPkg(t, "bkt3"), + }, + }, + expectedBkts: []string{"bkt1", "bkt2", "bkt3", "rucket-11"}, + }, + { + name: "retrieves packages from raw single and list", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: influxdb.ID(9000).String(), + RawTemplate: newBktPkg(t, "bkt4"), + RawTemplates: []pkger.ReqRawTemplate{ + newBktPkg(t, "bkt1"), + newBktPkg(t, "bkt2"), + newBktPkg(t, "bkt3"), + }, + }, + expectedBkts: []string{"bkt1", "bkt2", "bkt3", "bkt4"}, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := &fakeSVC{ + dryRunFn: func(ctx context.Context, orgID, userID influxdb.ID, opts ...pkger.ApplyOptFn) (pkger.ImpactSummary, error) { + var opt pkger.ApplyOpt + for _, o := range opts { + o(&opt) + } + pkg, err := pkger.Combine(opt.Pkgs) + if err != nil { + return pkger.ImpactSummary{}, err + } + + if err := pkg.Validate(); err != nil { + return pkger.ImpactSummary{}, err + } + sum := pkg.Summary() + var diff pkger.Diff + for _, b := range sum.Buckets { + diff.Buckets = append(diff.Buckets, pkger.DiffBucket{ + DiffIdentifier: pkger.DiffIdentifier{ + PkgName: b.Name, + }, + }) + } + + return pkger.ImpactSummary{ + Diff: diff, + Summary: sum, + }, nil + }, + } + + pkgHandler := pkger.NewHTTPServerTemplates(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PostJSON(t, "/api/v2/templates/apply", tt.reqBody). + Do(svr). + ExpectStatus(http.StatusOK). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespApply + decodeBody(t, buf, &resp) + + require.Len(t, resp.Summary.Buckets, len(tt.expectedBkts)) + for i, expected := range tt.expectedBkts { + assert.Equal(t, expected, resp.Summary.Buckets[i].Name) + } + }) + } + + t.Run(tt.name, fn) + } + }) + + t.Run("validation failures", func(t *testing.T) { + tests := []struct { + name string + contentType string + reqBody pkger.ReqApply + expectedStatusCode int + }{ + { + name: "invalid org id", + contentType: "application/json", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: "bad org id", + RawTemplate: bucketPkgKinds(t, pkger.EncodingJSON), + }, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "invalid stack id", + contentType: "application/json", + reqBody: pkger.ReqApply{ + DryRun: true, + OrgID: influxdb.ID(9000).String(), + StackID: strPtr("invalid stack id"), + RawTemplate: bucketPkgKinds(t, pkger.EncodingJSON), + }, + expectedStatusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + svc := &fakeSVC{ + dryRunFn: func(ctx context.Context, orgID, userID influxdb.ID, opts ...pkger.ApplyOptFn) (pkger.ImpactSummary, error) { + var opt pkger.ApplyOpt + for _, o := range opts { + o(&opt) + } + pkg, err := pkger.Combine(opt.Pkgs) + if err != nil { + return pkger.ImpactSummary{}, err + } + return pkger.ImpactSummary{ + Summary: pkg.Summary(), + }, nil + }, + } + + pkgHandler := pkger.NewHTTPServerTemplates(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PostJSON(t, "/api/v2/templates/apply", tt.reqBody). + Headers("Content-Type", tt.contentType). + Do(svr). + ExpectStatus(tt.expectedStatusCode) + } + + t.Run(tt.name, fn) + } + }) + }) + + t.Run("apply a pkg", func(t *testing.T) { + svc := &fakeSVC{ + applyFn: func(ctx context.Context, orgID, userID influxdb.ID, opts ...pkger.ApplyOptFn) (pkger.ImpactSummary, error) { + var opt pkger.ApplyOpt + for _, o := range opts { + o(&opt) + } + + pkg, err := pkger.Combine(opt.Pkgs) + if err != nil { + return pkger.ImpactSummary{}, err + } + + sum := pkg.Summary() + + var diff pkger.Diff + for _, b := range sum.Buckets { + diff.Buckets = append(diff.Buckets, pkger.DiffBucket{ + DiffIdentifier: pkger.DiffIdentifier{ + PkgName: b.Name, + }, + }) + } + for key := range opt.MissingSecrets { + sum.MissingSecrets = append(sum.MissingSecrets, key) + } + + return pkger.ImpactSummary{ + Diff: diff, + Summary: sum, + }, nil + }, + } + + pkgHandler := pkger.NewHTTPServerTemplates(zap.NewNop(), svc) + svr := newMountedHandler(pkgHandler, 1) + + testttp. + PostJSON(t, "/api/v2/templates/apply", pkger.ReqApply{ + OrgID: influxdb.ID(9000).String(), + Secrets: map[string]string{"secret1": "val1"}, + RawTemplate: bucketPkgKinds(t, pkger.EncodingJSON), + }). + Do(svr). + ExpectStatus(http.StatusCreated). + ExpectBody(func(buf *bytes.Buffer) { + var resp pkger.RespApply + decodeBody(t, buf, &resp) + + assert.Len(t, resp.Summary.Buckets, 1) + assert.Len(t, resp.Diff.Buckets, 1) + assert.Equal(t, []string{"secret1"}, resp.Summary.MissingSecrets) + assert.Nil(t, resp.Errors) + }) + }) +}