feat(pkger): add stable HTTP APIs for stacks and templates

references: #18580
pull/18793/head
Johnny Steenbergen 2020-06-29 11:16:55 -07:00 committed by Johnny Steenbergen
parent 9bd5de23bd
commit 2927f0e718
7 changed files with 2269 additions and 459 deletions

View File

@ -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())),

View File

@ -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

View File

@ -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 {

360
pkger/http_server_stack.go Normal file
View File

@ -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
}

View File

@ -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)
}
})
})
}

View File

@ -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,
}
}

View File

@ -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)
})
})
}