From 40ccbefc17f0b4d3d1b0291f14f3609f36ae6cee Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Sat, 3 Jun 2017 21:51:22 -0700 Subject: [PATCH] Implement Profile and Blueprint removal APIs and mdmctl UI (#206) --- blueprint/db.go | 26 ++++++++ cmd/mdmctl/mdmdctl.go | 4 ++ cmd/mdmctl/remove.go | 69 +++++++++++++++++++ cmd/mdmctl/remove_blueprints.go | 29 ++++++++ cmd/mdmctl/remove_profiles.go | 29 ++++++++ core/remove/client.go | 57 ++++++++++++++++ core/remove/endpoint.go | 78 ++++++++++++++++++++++ core/remove/service.go | 41 ++++++++++++ core/remove/transport_http.go | 114 ++++++++++++++++++++++++++++++++ profile/db.go | 12 ++++ serve.go | 6 ++ 11 files changed, 465 insertions(+) create mode 100644 cmd/mdmctl/remove.go create mode 100644 cmd/mdmctl/remove_blueprints.go create mode 100644 cmd/mdmctl/remove_profiles.go create mode 100644 core/remove/client.go create mode 100644 core/remove/endpoint.go create mode 100644 core/remove/service.go create mode 100644 core/remove/transport_http.go diff --git a/blueprint/db.go b/blueprint/db.go index 6fae6c59..32b8a3b4 100644 --- a/blueprint/db.go +++ b/blueprint/db.go @@ -164,6 +164,32 @@ func (db *DB) BlueprintsByApplyAt(name string) ([]*Blueprint, error) { return bps, err } +func (db *DB) Delete(name string) error { + bp, err := db.BlueprintByName(name) + if err != nil { + return err + } + err = db.Update(func(tx *bolt.Tx) error { + // TODO: reformulate into a transaction? + b := tx.Bucket([]byte(BlueprintBucket)) + i := tx.Bucket([]byte(blueprintIndexBucket)) + err := i.Delete([]byte(bp.Name)) + if err != nil { + return err + } + err = i.Delete([]byte(bp.UUID)) + if err != nil { + return err + } + err = b.Delete([]byte(bp.UUID)) + if err != nil { + return err + } + return nil + }) + return err +} + type notFound struct { ResourceType string Message string diff --git a/cmd/mdmctl/mdmdctl.go b/cmd/mdmctl/mdmdctl.go index e14abfd9..31da03cf 100644 --- a/cmd/mdmctl/mdmdctl.go +++ b/cmd/mdmctl/mdmdctl.go @@ -29,6 +29,9 @@ func main() { case "apply": cmd := &applyCommand{} run = cmd.Run + case "remove": + cmd := &removeCommand{} + run = cmd.Run default: usage() os.Exit(1) @@ -47,6 +50,7 @@ Available Commands: get apply config + remove version Use micromdm -h for additional usage of each command. diff --git a/cmd/mdmctl/remove.go b/cmd/mdmctl/remove.go new file mode 100644 index 00000000..17c951f0 --- /dev/null +++ b/cmd/mdmctl/remove.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/go-kit/kit/log" + + httptransport "github.com/go-kit/kit/transport/http" + "github.com/micromdm/micromdm/core/remove" +) + +type removeCommand struct { + config *ClientConfig + remove remove.Service +} + +func (cmd *removeCommand) setup() error { + cfg, err := LoadClientConfig() + if err != nil { + return err + } + cmd.config = cfg + logger := log.NewLogfmtLogger(os.Stderr) + rmsvc, err := remove.NewClient(cfg.ServerURL, logger, cfg.APIToken, httptransport.SetClient(skipVerifyHTTPClient(cmd.config.SkipVerify))) + if err != nil { + return err + } + cmd.remove = rmsvc + return nil +} + +func (cmd *removeCommand) Run(args []string) error { + if len(args) < 1 { + cmd.Usage() + os.Exit(1) + } + + if err := cmd.setup(); err != nil { + return err + } + + var run func([]string) error + switch strings.ToLower(args[0]) { + case "blueprints": + run = cmd.removeBlueprints + case "profiles": + run = cmd.removeProfiles + default: + cmd.Usage() + os.Exit(1) + } + + return run(args[1:]) +} + +func (cmd *removeCommand) Usage() error { + const getUsage = ` +Display one or many resources. + +Valid resource types: + + * blueprints + * profiles` + + fmt.Println(getUsage) + return nil +} diff --git a/cmd/mdmctl/remove_blueprints.go b/cmd/mdmctl/remove_blueprints.go new file mode 100644 index 00000000..e70d7a5a --- /dev/null +++ b/cmd/mdmctl/remove_blueprints.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "flag" + "fmt" + "strings" +) + +func (cmd *removeCommand) removeBlueprints(args []string) error { + flagset := flag.NewFlagSet("remove-blueprints", flag.ExitOnError) + var ( + flBlueprintName = flagset.String("name", "", "name of blueprint, optionally comma separated") + ) + flagset.Usage = usageFor(flagset, "mdmctl remove blueprints [flags]") + if err := flagset.Parse(args); err != nil { + return err + } + + ctx := context.Background() + err := cmd.remove.RemoveBlueprints(ctx, strings.Split(*flBlueprintName, ",")) + if err != nil { + return err + } + + fmt.Printf("removed blueprint(s): %s\n", *flBlueprintName) + + return nil +} diff --git a/cmd/mdmctl/remove_profiles.go b/cmd/mdmctl/remove_profiles.go new file mode 100644 index 00000000..fb0a68bb --- /dev/null +++ b/cmd/mdmctl/remove_profiles.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "flag" + "fmt" + "strings" +) + +func (cmd *removeCommand) removeProfiles(args []string) error { + flagset := flag.NewFlagSet("remove-profiles", flag.ExitOnError) + var ( + flIdentifier = flagset.String("id", "", "profile Identifier, optionally comma separated") + ) + flagset.Usage = usageFor(flagset, "mdmctl remove profiles [flags]") + if err := flagset.Parse(args); err != nil { + return err + } + + ctx := context.Background() + err := cmd.remove.RemoveProfiles(ctx, strings.Split(*flIdentifier, ",")) + if err != nil { + return err + } + + fmt.Printf("removed profile(s): %s\n", *flIdentifier) + + return nil +} diff --git a/core/remove/client.go b/core/remove/client.go new file mode 100644 index 00000000..9c5c1cd9 --- /dev/null +++ b/core/remove/client.go @@ -0,0 +1,57 @@ +package remove + +import ( + "context" + "net/http" + "net/url" + + "github.com/go-kit/kit/endpoint" + "github.com/go-kit/kit/log" + httptransport "github.com/go-kit/kit/transport/http" +) + +func NewClient(instance string, logger log.Logger, token string, opts ...httptransport.ClientOption) (Service, error) { + u, err := url.Parse(instance) + if err != nil { + return nil, err + } + + var removeBlueprintsEndpoint endpoint.Endpoint + { + removeBlueprintsEndpoint = httptransport.NewClient( + "DELETE", + copyURL(u, "/v1/blueprints"), + encodeRequestWithToken(token, EncodeHTTPGenericRequest), + DecodeBlueprintResponse, + opts..., + ).Endpoint() + } + var removeProfilesEndpoint endpoint.Endpoint + { + removeProfilesEndpoint = httptransport.NewClient( + "DELETE", + copyURL(u, "/v1/profiles"), + encodeRequestWithToken(token, EncodeHTTPGenericRequest), + DecodeProfileResponse, + opts..., + ).Endpoint() + } + + return Endpoints{ + RemoveBlueprintsEndpoint: removeBlueprintsEndpoint, + RemoveProfilesEndpoint: removeProfilesEndpoint, + }, nil +} + +func encodeRequestWithToken(token string, next httptransport.EncodeRequestFunc) httptransport.EncodeRequestFunc { + return func(ctx context.Context, r *http.Request, request interface{}) error { + r.SetBasicAuth("micromdm", token) + return next(ctx, r, request) + } +} + +func copyURL(base *url.URL, path string) *url.URL { + next := *base + next.Path = path + return &next +} diff --git a/core/remove/endpoint.go b/core/remove/endpoint.go new file mode 100644 index 00000000..e1e976fe --- /dev/null +++ b/core/remove/endpoint.go @@ -0,0 +1,78 @@ +package remove + +import ( + "context" + + "github.com/go-kit/kit/endpoint" +) + +type Endpoints struct { + RemoveBlueprintsEndpoint endpoint.Endpoint + RemoveProfilesEndpoint endpoint.Endpoint +} + +func MakeEndpoints(svc Service) Endpoints { + e := Endpoints{ + RemoveBlueprintsEndpoint: MakeRemoveBlueprintsEndpoint(svc), + RemoveProfilesEndpoint: MakeRemoveProfilesEndpoint(svc), + } + return e +} + +func (e Endpoints) RemoveBlueprints(ctx context.Context, names []string) error { + request := blueprintRequest{Names: names} + resp, err := e.RemoveBlueprintsEndpoint(ctx, request) + if err != nil { + return err + } + return resp.(blueprintResponse).Err +} + +func (e Endpoints) RemoveProfiles(ctx context.Context, ids []string) error { + request := profileRequest{Identifiers: ids} + resp, err := e.RemoveProfilesEndpoint(ctx, request) + if err != nil { + return err + } + return resp.(profileResponse).Err +} + +func MakeRemoveBlueprintsEndpoint(svc Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (response interface{}, err error) { + req := request.(blueprintRequest) + err = svc.RemoveBlueprints(ctx, req.Names) + return blueprintResponse{ + Err: err, + }, nil + } +} + +func MakeRemoveProfilesEndpoint(svc Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (response interface{}, err error) { + req := request.(profileRequest) + err = svc.RemoveProfiles(ctx, req.Identifiers) + return profileResponse{ + Err: err, + }, nil + } +} + +type blueprintRequest struct { + Names []string `json:"names"` +} + +type blueprintResponse struct { + Err error `json:"err,omitempty"` +} + +func (r blueprintResponse) error() error { return r.Err } + +type profileRequest struct { + Identifiers []string `json:"ids"` +} + +type profileResponse struct { + Err error `json:"err,omitempty"` +} + +func (r profileResponse) error() error { return r.Err } diff --git a/core/remove/service.go b/core/remove/service.go new file mode 100644 index 00000000..19ccfd09 --- /dev/null +++ b/core/remove/service.go @@ -0,0 +1,41 @@ +package remove + +import ( + "context" + "github.com/micromdm/micromdm/blueprint" + "github.com/micromdm/micromdm/profile" +) + +type Service interface { + RemoveBlueprints(ctx context.Context, names []string) error + RemoveProfiles(ctx context.Context, ids []string) error +} + +type RemoveService struct { + Blueprints *blueprint.DB + Profiles *profile.DB +} + +func (svc *RemoveService) RemoveBlueprints(ctx context.Context, names []string) error { + // TODO: Wrap deletion(s) in transactions so as to not have + // incomplete removals? + for _, name := range names { + err := svc.Blueprints.Delete(name) + if err != nil { + return err + } + } + return nil +} + +func (svc *RemoveService) RemoveProfiles(ctx context.Context, ids []string) error { + // TODO: Wrap deletion(s) in transactions so as to not have + // incomplete removals? + for _, id := range ids { + err := svc.Profiles.Delete(id) + if err != nil { + return err + } + } + return nil +} diff --git a/core/remove/transport_http.go b/core/remove/transport_http.go new file mode 100644 index 00000000..d9949c3c --- /dev/null +++ b/core/remove/transport_http.go @@ -0,0 +1,114 @@ +package remove + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + httptransport "github.com/go-kit/kit/transport/http" +) + +type HTTPHandlers struct { + BlueprintHandler http.Handler + ProfileHandler http.Handler +} + +func MakeHTTPHandlers(ctx context.Context, endpoint Endpoints, opts ...httptransport.ServerOption) HTTPHandlers { + h := HTTPHandlers{ + BlueprintHandler: httptransport.NewServer( + endpoint.RemoveBlueprintsEndpoint, + decodeBlueprintRequest, + encodeResponse, + opts..., + ), + ProfileHandler: httptransport.NewServer( + endpoint.RemoveProfilesEndpoint, + decodeProfileRequest, + encodeResponse, + opts..., + ), + } + return h +} + +func decodeBlueprintRequest(ctx context.Context, r *http.Request) (interface{}, error) { + var bpReq blueprintRequest + if err := json.NewDecoder(r.Body).Decode(&bpReq); err != nil { + return nil, err + } + return bpReq, nil +} + +func decodeProfileRequest(ctx context.Context, r *http.Request) (interface{}, error) { + var req profileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, err + } + return req, nil +} + +type errorWrapper struct { + Error string `json:"error"` +} + +type errorer interface { + error() error +} + +func errorDecoder(r *http.Response) error { + var w errorWrapper + if err := json.NewDecoder(r.Body).Decode(&w); err != nil { + return err + } + return errors.New(w.Error) +} + +func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error { + if e, ok := response.(errorer); ok && e.error() != nil { + EncodeError(ctx, e.error(), w) + return nil + } + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(response) +} + +func EncodeError(ctx context.Context, err error, w http.ResponseWriter) { + w.WriteHeader(http.StatusInternalServerError) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(errorWrapper{Error: err.Error()}) +} + +// EncodeHTTPGenericRequest is a transport/http.EncodeRequestFunc that +// JSON-encodes any request to the request body. Primarily useful in a client. +func EncodeHTTPGenericRequest(_ context.Context, r *http.Request, request interface{}) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(request); err != nil { + return err + } + r.Body = ioutil.NopCloser(&buf) + return nil +} + +func DecodeBlueprintResponse(_ context.Context, r *http.Response) (interface{}, error) { + if r.StatusCode != http.StatusOK { + return nil, errorDecoder(r) + } + var resp blueprintResponse + err := json.NewDecoder(r.Body).Decode(&resp) + return resp, err +} + +func DecodeProfileResponse(_ context.Context, r *http.Response) (interface{}, error) { + if r.StatusCode != http.StatusOK { + return nil, errorDecoder(r) + } + var resp profileResponse + err := json.NewDecoder(r.Body).Decode(&resp) + return resp, err +} diff --git a/profile/db.go b/profile/db.go index a44cd340..ddfd1887 100644 --- a/profile/db.go +++ b/profile/db.go @@ -83,6 +83,18 @@ func (db *DB) ProfileById(id string) (*Profile, error) { return &p, err } +func (db *DB) Delete(id string) error { + err := db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(ProfileBucket)) + v := b.Get([]byte(id)) + if v == nil { + return ¬Found{"Profile", fmt.Sprintf("id %s", id)} + } + return b.Delete([]byte(id)) + }) + return err +} + type notFound struct { ResourceType string Message string diff --git a/serve.go b/serve.go index cb081a97..10f3e488 100644 --- a/serve.go +++ b/serve.go @@ -46,6 +46,7 @@ import ( "github.com/micromdm/micromdm/connect" "github.com/micromdm/micromdm/core/apply" "github.com/micromdm/micromdm/core/list" + "github.com/micromdm/micromdm/core/remove" "github.com/micromdm/micromdm/crypto" "github.com/micromdm/micromdm/depsync" "github.com/micromdm/micromdm/deptoken" @@ -271,6 +272,9 @@ func serve(args []string) error { listAPIHandlers := list.MakeHTTPHandlers(ctx, listEndpoints, connectOpts...) + rmsvc := &remove.RemoveService{Blueprints: bpDB, Profiles: profDB} + removeAPIHandlers := remove.MakeHTTPHandlers(ctx, remove.MakeEndpoints(rmsvc), connectOpts...) + connectHandlers := connect.MakeHTTPHandlers(ctx, connectEndpoints, connectOpts...) pushHandlers := nanopush.MakeHTTPHandlers(ctx, pushEndpoints, checkinOpts...) @@ -296,8 +300,10 @@ func serve(args []string) error { r.Handle("/v1/dep-tokens", apiAuthMiddleware(*flAPIKey, applyAPIHandlers.DEPTokensHandler)).Methods("PUT") r.Handle("/v1/blueprints", apiAuthMiddleware(*flAPIKey, listAPIHandlers.GetBlueprintsHandler)).Methods("GET") r.Handle("/v1/blueprints", apiAuthMiddleware(*flAPIKey, applyAPIHandlers.BlueprintHandler)).Methods("PUT") + r.Handle("/v1/blueprints", apiAuthMiddleware(*flAPIKey, removeAPIHandlers.BlueprintHandler)).Methods("DELETE") r.Handle("/v1/profiles", apiAuthMiddleware(*flAPIKey, listAPIHandlers.GetProfilesHandler)).Methods("GET") r.Handle("/v1/profiles", apiAuthMiddleware(*flAPIKey, applyAPIHandlers.ProfileHandler)).Methods("PUT") + r.Handle("/v1/profiles", apiAuthMiddleware(*flAPIKey, removeAPIHandlers.ProfileHandler)).Methods("DELETE") r.Handle("/v1/dep/devices", apiAuthMiddleware(*flAPIKey, listAPIHandlers.GetDEPDeviceDetailsHandler)).Methods("GET") r.Handle("/v1/dep/account", apiAuthMiddleware(*flAPIKey, listAPIHandlers.GetDEPAccountInfoHandler)).Methods("GET") r.Handle("/v1/dep/profiles", apiAuthMiddleware(*flAPIKey, listAPIHandlers.GetDEPProfileHander)).Methods("GET")