From 000071e41472197d7e605f559f285ee6e2fdaa11 Mon Sep 17 00:00:00 2001 From: adfost Date: Thu, 11 Nov 2021 18:36:18 -0800 Subject: [PATCH] Add Tagging for Buckets to Console (#1193) --- integration/buckets_test.go | 147 ++++++++++++++++ models/put_bucket_tags_request.go | 67 +++++++ .../BucketDetails/AddBucketTagModal.tsx | 163 ++++++++++++++++++ .../BucketDetails/BucketSummaryPanel.tsx | 123 ++++++++++++- .../BucketDetails/DeleteBucketTagModal.tsx | 138 +++++++++++++++ .../src/screens/Console/Buckets/types.tsx | 5 + portal-ui/src/screens/Console/Watch/types.ts | 5 + restapi/client.go | 15 ++ restapi/embedded_spec.go | 92 ++++++++++ restapi/operations/console_api.go | 12 ++ .../operations/user_api/put_bucket_tags.go | 88 ++++++++++ .../user_api/put_bucket_tags_parameters.go | 127 ++++++++++++++ .../user_api/put_bucket_tags_responses.go | 113 ++++++++++++ .../user_api/put_bucket_tags_urlbuilder.go | 116 +++++++++++++ restapi/user_buckets.go | 65 ++++++- restapi/user_buckets_test.go | 22 +++ swagger-console.yml | 31 ++++ 17 files changed, 1319 insertions(+), 10 deletions(-) create mode 100644 models/put_bucket_tags_request.go create mode 100644 portal-ui/src/screens/Console/Buckets/BucketDetails/AddBucketTagModal.tsx create mode 100644 portal-ui/src/screens/Console/Buckets/BucketDetails/DeleteBucketTagModal.tsx create mode 100644 restapi/operations/user_api/put_bucket_tags.go create mode 100644 restapi/operations/user_api/put_bucket_tags_parameters.go create mode 100644 restapi/operations/user_api/put_bucket_tags_responses.go create mode 100644 restapi/operations/user_api/put_bucket_tags_urlbuilder.go diff --git a/integration/buckets_test.go b/integration/buckets_test.go index 14b313322..d1bda44d1 100644 --- a/integration/buckets_test.go +++ b/integration/buckets_test.go @@ -208,6 +208,153 @@ func TestAddBucket(t *testing.T) { } } +func TestGetBucket(t *testing.T) { + assert := assert.New(t) + + client := &http.Client{ + Timeout: 2 * time.Second, + } + + requestDataAdd := map[string]interface{}{ + "name": "test3", + "versioning": false, + "locking": false, + } + + requestDataJSON, _ := json.Marshal(requestDataAdd) + + requestDataBody := bytes.NewReader(requestDataJSON) + + // put bucket + request, err := http.NewRequest("POST", "http://localhost:9090/api/v1/buckets", requestDataBody) + if err != nil { + log.Println(err) + return + } + + request.Header.Add("Cookie", fmt.Sprintf("token=%s", token)) + request.Header.Add("Content-Type", "application/json") + + response, err := client.Do(request) + assert.Nil(err) + if err != nil { + log.Println(err) + return + } + + // get bucket + request, err = http.NewRequest("GET", "http://localhost:9090/api/v1/buckets/test3", nil) + if err != nil { + log.Println(err) + return + } + + request.Header.Add("Cookie", fmt.Sprintf("token=%s", token)) + request.Header.Add("Content-Type", "application/json") + + response, err = client.Do(request) + assert.Nil(err) + if err != nil { + log.Println(err) + return + } + + if response != nil { + assert.Equal(200, response.StatusCode, "Status Code is incorrect") + } +} + +func TestSetBucketTags(t *testing.T) { + assert := assert.New(t) + + client := &http.Client{ + Timeout: 2 * time.Second, + } + + requestDataAdd := map[string]interface{}{ + "name": "test4", + "versioning": false, + "locking": false, + } + + requestDataJSON, _ := json.Marshal(requestDataAdd) + + requestDataBody := bytes.NewReader(requestDataJSON) + + // put bucket + request, err := http.NewRequest("POST", "http://localhost:9090/api/v1/buckets", requestDataBody) + request.Close = true + if err != nil { + log.Println(err) + return + } + + request.Header.Add("Cookie", fmt.Sprintf("token=%s", token)) + request.Header.Add("Content-Type", "application/json") + + response, err := client.Do(request) + assert.Nil(err) + if err != nil { + log.Println(err) + return + } + + requestDataTags := map[string]interface{}{ + "tags": map[string]interface{}{ + "test": "TAG", + }, + } + + requestTagsJSON, _ := json.Marshal(requestDataTags) + + requestTagsBody := bytes.NewBuffer(requestTagsJSON) + + request, err = http.NewRequest(http.MethodPut, "http://localhost:9090/api/v1/buckets/test4/tags", requestTagsBody) + request.Close = true + if err != nil { + log.Println(err) + return + } + + request.Header.Add("Cookie", fmt.Sprintf("token=%s", token)) + request.Header.Add("Content-Type", "application/json") + + response, err = client.Do(request) + assert.Nil(err) + if err != nil { + log.Println(err) + return + } + + // get bucket + request, err = http.NewRequest("GET", "http://localhost:9090/api/v1/buckets/test4", nil) + request.Close = true + if err != nil { + log.Println(err) + return + } + + request.Header.Add("Cookie", fmt.Sprintf("token=%s", token)) + request.Header.Add("Content-Type", "application/json") + + response, err = client.Do(request) + assert.Nil(err) + if err != nil { + log.Println(err) + return + } + + bodyBytes, _ := ioutil.ReadAll(response.Body) + + bucket := models.Bucket{} + err = json.Unmarshal(bodyBytes, &bucket) + if err != nil { + log.Println(err) + } + + assert.Equal("TAG", bucket.Details.Tags["test"], "Failed to add tag") +} + func TestBucketVersioning(t *testing.T) { assert := assert.New(t) diff --git a/models/put_bucket_tags_request.go b/models/put_bucket_tags_request.go new file mode 100644 index 000000000..edbbe11a1 --- /dev/null +++ b/models/put_bucket_tags_request.go @@ -0,0 +1,67 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// PutBucketTagsRequest put bucket tags request +// +// swagger:model putBucketTagsRequest +type PutBucketTagsRequest struct { + + // tags + Tags map[string]string `json:"tags,omitempty"` +} + +// Validate validates this put bucket tags request +func (m *PutBucketTagsRequest) Validate(formats strfmt.Registry) error { + return nil +} + +// ContextValidate validates this put bucket tags request based on context it is used +func (m *PutBucketTagsRequest) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PutBucketTagsRequest) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PutBucketTagsRequest) UnmarshalBinary(b []byte) error { + var res PutBucketTagsRequest + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/portal-ui/src/screens/Console/Buckets/BucketDetails/AddBucketTagModal.tsx b/portal-ui/src/screens/Console/Buckets/BucketDetails/AddBucketTagModal.tsx new file mode 100644 index 000000000..f6de56276 --- /dev/null +++ b/portal-ui/src/screens/Console/Buckets/BucketDetails/AddBucketTagModal.tsx @@ -0,0 +1,163 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { useState } from "react"; +import get from "lodash/get"; +import { connect } from "react-redux"; +import { Button, Grid } from "@mui/material"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { modalBasic } from "../../Common/FormComponents/common/styleLibrary"; +import { setModalErrorSnackMessage } from "../../../../actions"; +import { AppState } from "../../../../store"; +import { ErrorResponseHandler } from "../../../../common/types"; +import InputBoxWrapper from "../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; +import ModalWrapper from "../../Common/ModalWrapper/ModalWrapper"; +import api from "../../../../common/api"; + +interface IBucketTagModal { + modalOpen: boolean; + currentTags: any; + bucketName: string; + onCloseAndUpdate: (refresh: boolean) => void; + setModalErrorSnackMessage: typeof setModalErrorSnackMessage; + classes: any; +} + +const styles = (theme: Theme) => + createStyles({ + buttonContainer: { + textAlign: "right", + }, + pathLabel: { + marginTop: 0, + marginBottom: 32, + }, + ...modalBasic, + }); + +const AddBucketTagModal = ({ + modalOpen, + currentTags, + onCloseAndUpdate, + bucketName, + setModalErrorSnackMessage, + classes, +}: IBucketTagModal) => { + const [newKey, setNewKey] = useState(""); + const [newLabel, setNewLabel] = useState(""); + const [isSending, setIsSending] = useState(false); + + const resetForm = () => { + setNewLabel(""); + setNewKey(""); + }; + + const addTagProcess = () => { + setIsSending(true); + const newTag: any = {}; + + newTag[newKey] = newLabel; + const newTagList = { ...currentTags, ...newTag }; + + api + .invoke("PUT", `/api/v1/buckets/${bucketName}/tags`, { + tags: newTagList, + }) + .then((res: any) => { + setIsSending(false); + onCloseAndUpdate(true); + }) + .catch((error: ErrorResponseHandler) => { + setModalErrorSnackMessage(error); + setIsSending(false); + }); + }; + + return ( + + { + onCloseAndUpdate(false); + }} + > + +

Bucket: {bucketName}

+ + { + setNewKey(e.target.value); + }} + /> + + + { + setNewLabel(e.target.value); + }} + /> + + + + + +
+
+
+ ); +}; + +const mapStateToProps = ({ system }: AppState) => ({ + distributedSetup: get(system, "distributedSetup", false), +}); + +const mapDispatchToProps = { + setModalErrorSnackMessage, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export default withStyles(styles)(connector(AddBucketTagModal)); diff --git a/portal-ui/src/screens/Console/Buckets/BucketDetails/BucketSummaryPanel.tsx b/portal-ui/src/screens/Console/Buckets/BucketDetails/BucketSummaryPanel.tsx index 19705549a..8975da595 100644 --- a/portal-ui/src/screens/Console/Buckets/BucketDetails/BucketSummaryPanel.tsx +++ b/portal-ui/src/screens/Console/Buckets/BucketDetails/BucketSummaryPanel.tsx @@ -34,8 +34,8 @@ import { BucketReplication, BucketVersioning, } from "../types"; -import { niceBytes } from "../../../../common/utils"; -import { BucketList } from "../../Watch/types"; +import { encodeFileName, niceBytes } from "../../../../common/utils"; +import { Bucket, BucketList } from "../../Watch/types"; import { buttonsStyles, hrClass, @@ -68,7 +68,13 @@ import { S3_PUT_BUCKET_VERSIONING, S3_PUT_OBJECT_RETENTION, } from "../../../../types"; + import PanelTitle from "../../Common/PanelTitle/PanelTitle"; +import Chip from "@mui/material/Chip"; +import AddIcon from "@mui/icons-material/Add"; +import CloseIcon from "@mui/icons-material/Close"; +import AddBucketTagModal from "./AddBucketTagModal"; +import DeleteBucketTagModal from "./DeleteBucketTagModal"; interface IBucketSummaryProps { classes: any; @@ -117,6 +123,10 @@ const styles = (theme: Theme) => titleCol: { width: "25%", }, + tag: { + textTransform: "none", + marginRight: "5px", + }, ...hrClass, ...buttonsStyles, }); @@ -139,6 +149,7 @@ const BucketSummary = ({ const [replicationRules, setReplicationRules] = useState(false); const [loadingObjectLocking, setLoadingLocking] = useState(true); const [loadingSize, setLoadingSize] = useState(true); + const [loadingTags, setLoadingTags] = useState(true); const [bucketLoading, setBucketLoading] = useState(true); const [loadingEncryption, setLoadingEncryption] = useState(true); const [loadingVersioning, setLoadingVersioning] = useState(true); @@ -160,6 +171,11 @@ const BucketSummary = ({ useState(false); const [enableVersioningOpen, setEnableVersioningOpen] = useState(false); + const [tags, setTags] = useState(null); + const [tagModalOpen, setTagModalOpen] = useState(false); + const [tagKeys, setTagKeys] = useState([]); + const [selectedTag, setSelectedTag] = useState(["", ""]); + const [deleteTagModalOpen, setDeleteTagModalOpen] = useState(false); const bucketName = match.params["bucketName"]; @@ -347,6 +363,7 @@ const BucketSummary = ({ const bucketInfo = resBuckets.find( (bucket) => bucket.name === bucketName ); + const size = get(bucketInfo, "size", "0"); setLoadingSize(false); @@ -359,6 +376,32 @@ const BucketSummary = ({ } }, [loadingSize, setErrorSnackMessage, bucketName]); + useEffect(() => { + if (loadingTags) { + api + .invoke("GET", `/api/v1/buckets/${bucketName}`) + .then((res: Bucket) => { + if (res != null && res?.details != null) { + setTags(res?.details?.tags); + setTagKeys(Object.keys(res?.details?.tags)); + } + setLoadingTags(false); + }) + .catch((err: ErrorResponseHandler) => { + setLoadingTags(false); + setErrorSnackMessage(err); + }); + } + }, [ + loadingTags, + setErrorSnackMessage, + bucketName, + setTags, + tags, + setTagKeys, + tagKeys, + ]); + useEffect(() => { if (loadingReplication && distributedSetup) { api @@ -396,6 +439,7 @@ const BucketSummary = ({ setBucketDetailsLoad(true); setBucketLoading(true); setLoadingSize(true); + setLoadingTags(true); setLoadingVersioning(true); setLoadingEncryption(true); setLoadingRetention(true); @@ -434,6 +478,13 @@ const BucketSummary = ({ } }; + const closeAddTagModal = (refresh: boolean) => { + setTagModalOpen(false); + if (refresh) { + loadAllBucketData(); + } + }; + const cap = (str: string) => { if (!str) { return null; @@ -441,6 +492,20 @@ const BucketSummary = ({ return str[0].toUpperCase() + str.slice(1); }; + const deleteTag = (tagKey: string, tagLabel: string) => { + setSelectedTag([tagKey, tagLabel]); + setDeleteTagModalOpen(true); + }; + + const closeDeleteTagModal = (refresh: boolean) => { + setDeleteTagModalOpen(false); + + if (refresh) { + loadAllBucketData(); + } + }; + + // @ts-ignore return ( {enableEncryptionScreenOpen && ( @@ -484,6 +549,23 @@ const BucketSummary = ({ versioningCurrentState={isVersioned} /> )} + {tagModalOpen && ( + + )} + {deleteTagModalOpen && ( + + )} Summary @@ -564,6 +646,43 @@ const BucketSummary = ({ )} + + Tags: + + {tagKeys && + tagKeys.map((tagKey: any, index: any) => { + const tag = get(tags, `${tagKey}`, ""); + if (tag !== "") { + return ( + } + onDelete={() => { + deleteTag(tagKey, tag); + }} + /> + ); + } + return null; + })} + } + clickable + size="small" + label="Add tag" + color="primary" + variant="outlined" + onClick={() => { + setTagModalOpen(true); + }} + /> + + diff --git a/portal-ui/src/screens/Console/Buckets/BucketDetails/DeleteBucketTagModal.tsx b/portal-ui/src/screens/Console/Buckets/BucketDetails/DeleteBucketTagModal.tsx new file mode 100644 index 000000000..6a9912365 --- /dev/null +++ b/portal-ui/src/screens/Console/Buckets/BucketDetails/DeleteBucketTagModal.tsx @@ -0,0 +1,138 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { useState } from "react"; +import get from "lodash/get"; +import { connect } from "react-redux"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + LinearProgress, +} from "@mui/material"; +import { Theme } from "@mui/material/styles"; +import createStyles from "@mui/styles/createStyles"; +import withStyles from "@mui/styles/withStyles"; +import { modalBasic } from "../../Common/FormComponents/common/styleLibrary"; +import { setErrorSnackMessage } from "../../../../actions"; +import { AppState } from "../../../../store"; +import { ErrorResponseHandler } from "../../../../common/types"; +import api from "../../../../common/api"; + +interface IDeleteBucketTagModal { + deleteOpen: boolean; + currentTags: any; + bucketName: string; + selectedTag: string[]; + onCloseAndUpdate: (refresh: boolean) => void; + setErrorSnackMessage: typeof setErrorSnackMessage; + classes: any; +} + +const styles = (theme: Theme) => + createStyles({ + buttonContainer: { + textAlign: "right", + }, + pathLabel: { + marginTop: 0, + marginBottom: 32, + }, + ...modalBasic, + }); + +const DeleteBucketTagModal = ({ + deleteOpen, + currentTags, + selectedTag, + onCloseAndUpdate, + bucketName, + setErrorSnackMessage, + classes, +}: IDeleteBucketTagModal) => { + const [deleteLoading, setDeleteSending] = useState(false); + const [tagKey, tagLabel] = selectedTag; + + const removeTagProcess = () => { + setDeleteSending(true); + const cleanObject = { ...currentTags }; + delete cleanObject[tagKey]; + + api + .invoke("PUT", `/api/v1/buckets/${bucketName}/tags`, { + tags: cleanObject, + }) + .then((res: any) => { + setDeleteSending(false); + onCloseAndUpdate(true); + }) + .catch((error: ErrorResponseHandler) => { + setErrorSnackMessage(error); + setDeleteSending(false); + }); + }; + + return ( + { + onCloseAndUpdate(false); + }} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + Delete Tag + + {deleteLoading && } + + Are you sure you want to delete the tag{" "} + + {tagKey} : {tagLabel} + + + + + + + + + ); +}; + +const mapStateToProps = ({ system }: AppState) => ({ + distributedSetup: get(system, "distributedSetup", false), +}); + +const mapDispatchToProps = { + setErrorSnackMessage, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export default withStyles(styles)(connector(DeleteBucketTagModal)); diff --git a/portal-ui/src/screens/Console/Buckets/types.tsx b/portal-ui/src/screens/Console/Buckets/types.tsx index 5a3d3135f..2d6405ab0 100644 --- a/portal-ui/src/screens/Console/Buckets/types.tsx +++ b/portal-ui/src/screens/Console/Buckets/types.tsx @@ -27,6 +27,7 @@ export interface Bucket { rw_access?: RwAccess; allowedActions?: string[]; manage: boolean; + details?: Details; } export interface BucketEncryptionInfo { @@ -34,6 +35,10 @@ export interface BucketEncryptionInfo { kmsMasterKeyID: string; } +export interface Details { + tags: object; +} + export interface BucketInfo { name: string; access: string; diff --git a/portal-ui/src/screens/Console/Watch/types.ts b/portal-ui/src/screens/Console/Watch/types.ts index 24010aa73..7c392c87b 100644 --- a/portal-ui/src/screens/Console/Watch/types.ts +++ b/portal-ui/src/screens/Console/Watch/types.ts @@ -27,9 +27,14 @@ export interface EventInfo { } export interface Bucket { + details: Details; name: string; } +export interface Details { + tags: object; +} + export interface BucketList { buckets: Bucket[]; total: number; diff --git a/restapi/client.go b/restapi/client.go index e789ce055..b85c73b16 100644 --- a/restapi/client.go +++ b/restapi/client.go @@ -75,6 +75,9 @@ type MinioClient interface { getLifecycleRules(ctx context.Context, bucketName string) (lifecycle *lifecycle.Configuration, err error) setBucketLifecycle(ctx context.Context, bucketName string, config *lifecycle.Configuration) error copyObject(ctx context.Context, dst minio.CopyDestOptions, src minio.CopySrcOptions) (minio.UploadInfo, error) + GetBucketTagging(ctx context.Context, bucketName string) (*tags.Tags, error) + SetBucketTagging(ctx context.Context, bucketName string, tags *tags.Tags) error + RemoveBucketTagging(ctx context.Context, bucketName string) error } // Interface implementation @@ -85,6 +88,18 @@ type minioClient struct { client *minio.Client } +func (c minioClient) GetBucketTagging(ctx context.Context, bucketName string) (*tags.Tags, error) { + return c.client.GetBucketTagging(ctx, bucketName) +} + +func (c minioClient) SetBucketTagging(ctx context.Context, bucketName string, tags *tags.Tags) error { + return c.client.SetBucketTagging(ctx, bucketName, tags) +} + +func (c minioClient) RemoveBucketTagging(ctx context.Context, bucketName string) error { + return c.client.RemoveBucketTagging(ctx, bucketName) +} + // implements minio.ListBuckets(ctx) func (c minioClient) listBucketsWithContext(ctx context.Context) ([]minio.BucketInfo, error) { return c.client.ListBuckets(ctx) diff --git a/restapi/embedded_spec.go b/restapi/embedded_spec.go index ddf38af52..43638dbf2 100644 --- a/restapi/embedded_spec.go +++ b/restapi/embedded_spec.go @@ -1729,6 +1729,42 @@ func init() { } } }, + "/buckets/{bucket_name}/tags": { + "put": { + "tags": [ + "UserAPI" + ], + "summary": "Put Bucket's tags", + "operationId": "PutBucketTags", + "parameters": [ + { + "type": "string", + "name": "bucket_name", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/putBucketTagsRequest" + } + } + ], + "responses": { + "200": { + "description": "A successful response." + }, + "default": { + "description": "Generic error response.", + "schema": { + "$ref": "#/definitions/error" + } + } + } + } + }, "/buckets/{bucket_name}/versioning": { "get": { "tags": [ @@ -4886,6 +4922,16 @@ func init() { } } }, + "putBucketTagsRequest": { + "type": "object", + "properties": { + "tags": { + "additionalProperties": { + "type": "string" + } + } + } + }, "putObjectLegalHoldRequest": { "type": "object", "required": [ @@ -7384,6 +7430,42 @@ func init() { } } }, + "/buckets/{bucket_name}/tags": { + "put": { + "tags": [ + "UserAPI" + ], + "summary": "Put Bucket's tags", + "operationId": "PutBucketTags", + "parameters": [ + { + "type": "string", + "name": "bucket_name", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/putBucketTagsRequest" + } + } + ], + "responses": { + "200": { + "description": "A successful response." + }, + "default": { + "description": "Generic error response.", + "schema": { + "$ref": "#/definitions/error" + } + } + } + } + }, "/buckets/{bucket_name}/versioning": { "get": { "tags": [ @@ -10661,6 +10743,16 @@ func init() { } } }, + "putBucketTagsRequest": { + "type": "object", + "properties": { + "tags": { + "additionalProperties": { + "type": "string" + } + } + } + }, "putObjectLegalHoldRequest": { "type": "object", "required": [ diff --git a/restapi/operations/console_api.go b/restapi/operations/console_api.go index ce7733a33..540fea8d9 100644 --- a/restapi/operations/console_api.go +++ b/restapi/operations/console_api.go @@ -284,6 +284,9 @@ func NewConsoleAPI(spec *loads.Document) *ConsoleAPI { AdminAPIProfilingStopHandler: admin_api.ProfilingStopHandlerFunc(func(params admin_api.ProfilingStopParams, principal *models.Principal) middleware.Responder { return middleware.NotImplemented("operation admin_api.ProfilingStop has not yet been implemented") }), + UserAPIPutBucketTagsHandler: user_api.PutBucketTagsHandlerFunc(func(params user_api.PutBucketTagsParams, principal *models.Principal) middleware.Responder { + return middleware.NotImplemented("operation user_api.PutBucketTags has not yet been implemented") + }), UserAPIPutObjectLegalHoldHandler: user_api.PutObjectLegalHoldHandlerFunc(func(params user_api.PutObjectLegalHoldParams, principal *models.Principal) middleware.Responder { return middleware.NotImplemented("operation user_api.PutObjectLegalHold has not yet been implemented") }), @@ -560,6 +563,8 @@ type ConsoleAPI struct { AdminAPIProfilingStartHandler admin_api.ProfilingStartHandler // AdminAPIProfilingStopHandler sets the operation handler for the profiling stop operation AdminAPIProfilingStopHandler admin_api.ProfilingStopHandler + // UserAPIPutBucketTagsHandler sets the operation handler for the put bucket tags operation + UserAPIPutBucketTagsHandler user_api.PutBucketTagsHandler // UserAPIPutObjectLegalHoldHandler sets the operation handler for the put object legal hold operation UserAPIPutObjectLegalHoldHandler user_api.PutObjectLegalHoldHandler // UserAPIPutObjectRestoreHandler sets the operation handler for the put object restore operation @@ -916,6 +921,9 @@ func (o *ConsoleAPI) Validate() error { if o.AdminAPIProfilingStopHandler == nil { unregistered = append(unregistered, "admin_api.ProfilingStopHandler") } + if o.UserAPIPutBucketTagsHandler == nil { + unregistered = append(unregistered, "user_api.PutBucketTagsHandler") + } if o.UserAPIPutObjectLegalHoldHandler == nil { unregistered = append(unregistered, "user_api.PutObjectLegalHoldHandler") } @@ -1388,6 +1396,10 @@ func (o *ConsoleAPI) initHandlerCache() { if o.handlers["PUT"] == nil { o.handlers["PUT"] = make(map[string]http.Handler) } + o.handlers["PUT"]["/buckets/{bucket_name}/tags"] = user_api.NewPutBucketTags(o.context, o.UserAPIPutBucketTagsHandler) + if o.handlers["PUT"] == nil { + o.handlers["PUT"] = make(map[string]http.Handler) + } o.handlers["PUT"]["/buckets/{bucket_name}/objects/legalhold"] = user_api.NewPutObjectLegalHold(o.context, o.UserAPIPutObjectLegalHoldHandler) if o.handlers["PUT"] == nil { o.handlers["PUT"] = make(map[string]http.Handler) diff --git a/restapi/operations/user_api/put_bucket_tags.go b/restapi/operations/user_api/put_bucket_tags.go new file mode 100644 index 000000000..6f2210333 --- /dev/null +++ b/restapi/operations/user_api/put_bucket_tags.go @@ -0,0 +1,88 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package user_api + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime/middleware" + + "github.com/minio/console/models" +) + +// PutBucketTagsHandlerFunc turns a function with the right signature into a put bucket tags handler +type PutBucketTagsHandlerFunc func(PutBucketTagsParams, *models.Principal) middleware.Responder + +// Handle executing the request and returning a response +func (fn PutBucketTagsHandlerFunc) Handle(params PutBucketTagsParams, principal *models.Principal) middleware.Responder { + return fn(params, principal) +} + +// PutBucketTagsHandler interface for that can handle valid put bucket tags params +type PutBucketTagsHandler interface { + Handle(PutBucketTagsParams, *models.Principal) middleware.Responder +} + +// NewPutBucketTags creates a new http.Handler for the put bucket tags operation +func NewPutBucketTags(ctx *middleware.Context, handler PutBucketTagsHandler) *PutBucketTags { + return &PutBucketTags{Context: ctx, Handler: handler} +} + +/* PutBucketTags swagger:route PUT /buckets/{bucket_name}/tags UserAPI putBucketTags + +Put Bucket's tags + +*/ +type PutBucketTags struct { + Context *middleware.Context + Handler PutBucketTagsHandler +} + +func (o *PutBucketTags) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + route, rCtx, _ := o.Context.RouteInfo(r) + if rCtx != nil { + *r = *rCtx + } + var Params = NewPutBucketTagsParams() + uprinc, aCtx, err := o.Context.Authorize(r, route) + if err != nil { + o.Context.Respond(rw, r, route.Produces, route, err) + return + } + if aCtx != nil { + *r = *aCtx + } + var principal *models.Principal + if uprinc != nil { + principal = uprinc.(*models.Principal) // this is really a models.Principal, I promise + } + + if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params + o.Context.Respond(rw, r, route.Produces, route, err) + return + } + + res := o.Handler.Handle(Params, principal) // actually handle the request + o.Context.Respond(rw, r, route.Produces, route, res) + +} diff --git a/restapi/operations/user_api/put_bucket_tags_parameters.go b/restapi/operations/user_api/put_bucket_tags_parameters.go new file mode 100644 index 000000000..ab0bdec59 --- /dev/null +++ b/restapi/operations/user_api/put_bucket_tags_parameters.go @@ -0,0 +1,127 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package user_api + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "io" + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/validate" + + "github.com/minio/console/models" +) + +// NewPutBucketTagsParams creates a new PutBucketTagsParams object +// +// There are no default values defined in the spec. +func NewPutBucketTagsParams() PutBucketTagsParams { + + return PutBucketTagsParams{} +} + +// PutBucketTagsParams contains all the bound params for the put bucket tags operation +// typically these are obtained from a http.Request +// +// swagger:parameters PutBucketTags +type PutBucketTagsParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /* + Required: true + In: body + */ + Body *models.PutBucketTagsRequest + /* + Required: true + In: path + */ + BucketName string +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewPutBucketTagsParams() beforehand. +func (o *PutBucketTagsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body models.PutBucketTagsRequest + if err := route.Consumer.Consume(r.Body, &body); err != nil { + if err == io.EOF { + res = append(res, errors.Required("body", "body", "")) + } else { + res = append(res, errors.NewParseError("body", "body", "", err)) + } + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + ctx := validate.WithOperationRequest(context.Background()) + if err := body.ContextValidate(ctx, route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Body = &body + } + } + } else { + res = append(res, errors.Required("body", "body", "")) + } + + rBucketName, rhkBucketName, _ := route.Params.GetOK("bucket_name") + if err := o.bindBucketName(rBucketName, rhkBucketName, route.Formats); err != nil { + res = append(res, err) + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// bindBucketName binds and validates parameter BucketName from path. +func (o *PutBucketTagsParams) bindBucketName(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: true + // Parameter is provided by construction from the route + o.BucketName = raw + + return nil +} diff --git a/restapi/operations/user_api/put_bucket_tags_responses.go b/restapi/operations/user_api/put_bucket_tags_responses.go new file mode 100644 index 000000000..09b7e9250 --- /dev/null +++ b/restapi/operations/user_api/put_bucket_tags_responses.go @@ -0,0 +1,113 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package user_api + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime" + + "github.com/minio/console/models" +) + +// PutBucketTagsOKCode is the HTTP code returned for type PutBucketTagsOK +const PutBucketTagsOKCode int = 200 + +/*PutBucketTagsOK A successful response. + +swagger:response putBucketTagsOK +*/ +type PutBucketTagsOK struct { +} + +// NewPutBucketTagsOK creates PutBucketTagsOK with default headers values +func NewPutBucketTagsOK() *PutBucketTagsOK { + + return &PutBucketTagsOK{} +} + +// WriteResponse to the client +func (o *PutBucketTagsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses + + rw.WriteHeader(200) +} + +/*PutBucketTagsDefault Generic error response. + +swagger:response putBucketTagsDefault +*/ +type PutBucketTagsDefault struct { + _statusCode int + + /* + In: Body + */ + Payload *models.Error `json:"body,omitempty"` +} + +// NewPutBucketTagsDefault creates PutBucketTagsDefault with default headers values +func NewPutBucketTagsDefault(code int) *PutBucketTagsDefault { + if code <= 0 { + code = 500 + } + + return &PutBucketTagsDefault{ + _statusCode: code, + } +} + +// WithStatusCode adds the status to the put bucket tags default response +func (o *PutBucketTagsDefault) WithStatusCode(code int) *PutBucketTagsDefault { + o._statusCode = code + return o +} + +// SetStatusCode sets the status to the put bucket tags default response +func (o *PutBucketTagsDefault) SetStatusCode(code int) { + o._statusCode = code +} + +// WithPayload adds the payload to the put bucket tags default response +func (o *PutBucketTagsDefault) WithPayload(payload *models.Error) *PutBucketTagsDefault { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the put bucket tags default response +func (o *PutBucketTagsDefault) SetPayload(payload *models.Error) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *PutBucketTagsDefault) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(o._statusCode) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} diff --git a/restapi/operations/user_api/put_bucket_tags_urlbuilder.go b/restapi/operations/user_api/put_bucket_tags_urlbuilder.go new file mode 100644 index 000000000..26fe609b3 --- /dev/null +++ b/restapi/operations/user_api/put_bucket_tags_urlbuilder.go @@ -0,0 +1,116 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package user_api + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "errors" + "net/url" + golangswaggerpaths "path" + "strings" +) + +// PutBucketTagsURL generates an URL for the put bucket tags operation +type PutBucketTagsURL struct { + BucketName string + + _basePath string + // avoid unkeyed usage + _ struct{} +} + +// WithBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *PutBucketTagsURL) WithBasePath(bp string) *PutBucketTagsURL { + o.SetBasePath(bp) + return o +} + +// SetBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *PutBucketTagsURL) SetBasePath(bp string) { + o._basePath = bp +} + +// Build a url path and query string +func (o *PutBucketTagsURL) Build() (*url.URL, error) { + var _result url.URL + + var _path = "/buckets/{bucket_name}/tags" + + bucketName := o.BucketName + if bucketName != "" { + _path = strings.Replace(_path, "{bucket_name}", bucketName, -1) + } else { + return nil, errors.New("bucketName is required on PutBucketTagsURL") + } + + _basePath := o._basePath + if _basePath == "" { + _basePath = "/api/v1" + } + _result.Path = golangswaggerpaths.Join(_basePath, _path) + + return &_result, nil +} + +// Must is a helper function to panic when the url builder returns an error +func (o *PutBucketTagsURL) Must(u *url.URL, err error) *url.URL { + if err != nil { + panic(err) + } + if u == nil { + panic("url can't be nil") + } + return u +} + +// String returns the string representation of the path with query string +func (o *PutBucketTagsURL) String() string { + return o.Must(o.Build()).String() +} + +// BuildFull builds a full url with scheme, host, path and query string +func (o *PutBucketTagsURL) BuildFull(scheme, host string) (*url.URL, error) { + if scheme == "" { + return nil, errors.New("scheme is required for a full url on PutBucketTagsURL") + } + if host == "" { + return nil, errors.New("host is required for a full url on PutBucketTagsURL") + } + + base, err := o.Build() + if err != nil { + return nil, err + } + + base.Scheme = scheme + base.Host = host + return base, nil +} + +// StringFull returns the string representation of a complete url +func (o *PutBucketTagsURL) StringFull(scheme, host string) string { + return o.Must(o.BuildFull(scheme, host)).String() +} diff --git a/restapi/user_buckets.go b/restapi/user_buckets.go index 4485feadb..2c2044a3f 100644 --- a/restapi/user_buckets.go +++ b/restapi/user_buckets.go @@ -30,6 +30,7 @@ import ( "github.com/minio/mc/pkg/probe" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/sse" + "github.com/minio/minio-go/v7/pkg/tags" "errors" @@ -83,6 +84,14 @@ func registerBucketsHandlers(api *operations.ConsoleAPI) { } return user_api.NewBucketSetPolicyOK().WithPayload(bucketSetPolicyResp) }) + // set bucket tags + api.UserAPIPutBucketTagsHandler = user_api.PutBucketTagsHandlerFunc(func(params user_api.PutBucketTagsParams, session *models.Principal) middleware.Responder { + err := getPutBucketTagsResponse(session, params.BucketName, params.Body) + if err != nil { + return user_api.NewPutBucketTagsDefault(int(err.Code)).WithPayload(err) + } + return user_api.NewPutBucketTagsOK() + }) // get bucket versioning api.UserAPIGetBucketVersioningHandler = user_api.GetBucketVersioningHandlerFunc(func(params user_api.GetBucketVersioningParams, session *models.Principal) middleware.Responder { getBucketVersioning, err := getBucketVersionedResponse(session, params.BucketName) @@ -521,6 +530,32 @@ func getBucketSetPolicyResponse(session *models.Principal, bucketName string, re return bucket, nil } +// putBucketTags sets tags for a bucket +func getPutBucketTagsResponse(session *models.Principal, bucketName string, req *models.PutBucketTagsRequest) *models.Error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*20) + defer cancel() + + mClient, err := newMinioClient(session) + if err != nil { + return prepareError(err) + } + // create a minioClient interface implementation + // defining the client to be used + minioClient := minioClient{client: mClient} + + newTagSet, err := tags.NewTags(req.Tags, true) + if err != nil { + return prepareError(err) + } + + err = minioClient.SetBucketTagging(ctx, bucketName, newTagSet) + + if err != nil { + return prepareError(err) + } + return nil +} + // removeBucket deletes a bucket func removeBucket(client MinioClient, bucketName string) error { return client.removeBucket(context.Background(), bucketName) @@ -606,14 +641,28 @@ func getBucketInfo(ctx context.Context, client MinioClient, adminClient MinioAdm if bucketAccess == models.BucketAccessPRIVATE && policyStr != "" { bucketAccess = models.BucketAccessCUSTOM } - - bucket := &models.Bucket{ - Name: &bucketName, - Access: &bucketAccess, - CreationDate: "", // to be implemented - Size: 0, // to be implemented - AllowedActions: bucketActionsArray, - Manage: bucketAdminRole, + bucketTags, err := client.GetBucketTagging(ctx, bucketName) + var bucket *models.Bucket + if err == nil && bucketTags != nil { + bucket = &models.Bucket{ + Name: &bucketName, + Access: &bucketAccess, + CreationDate: "", // to be implemented + Size: 0, // to be implemented + AllowedActions: bucketActionsArray, + Manage: bucketAdminRole, + Details: &models.BucketDetails{Tags: bucketTags.ToMap()}, + } + } else { + bucket = &models.Bucket{ + Name: &bucketName, + Access: &bucketAccess, + CreationDate: "", // to be implemented + Size: 0, // to be implemented + AllowedActions: bucketActionsArray, + Manage: bucketAdminRole, + Details: &models.BucketDetails{}, + } } return bucket, nil } diff --git a/restapi/user_buckets_test.go b/restapi/user_buckets_test.go index cef4ba68f..9770eed05 100644 --- a/restapi/user_buckets_test.go +++ b/restapi/user_buckets_test.go @@ -31,6 +31,7 @@ import ( "github.com/minio/mc/pkg/probe" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/sse" + "github.com/minio/minio-go/v7/pkg/tags" "github.com/stretchr/testify/assert" ) @@ -48,6 +49,8 @@ var minioGetBucketObjectLockConfigMock func(ctx context.Context, bucketName stri var minioGetObjectLockConfigMock func(ctx context.Context, bucketName string) (lock string, mode *minio.RetentionMode, validity *uint, unit *minio.ValidityUnit, err error) var minioSetVersioningMock func(ctx context.Context, state string) *probe.Error var minioCopyObjectMock func(ctx context.Context, dst minio.CopyDestOptions, src minio.CopySrcOptions) (minio.UploadInfo, error) +var minioSetBucketTaggingMock func(ctx context.Context, bucketName string, tags *tags.Tags) error +var minioRemoveBucketTaggingMock func(ctx context.Context, bucketName string) error // Define a mock struct of minio Client interface implementation type minioClientMock struct { @@ -116,6 +119,25 @@ func (ac adminClientMock) AccountInfo(ctx context.Context) (madmin.AccountInfo, return minioAccountInfoMock(ctx) } +func (mc minioClientMock) GetBucketTagging(ctx context.Context, bucketName string) (*tags.Tags, error) { + return minioGetBucketTaggingMock(ctx, bucketName) +} + +func (mc minioClientMock) SetBucketTagging(ctx context.Context, bucketName string, tags *tags.Tags) error { + return minioSetBucketTaggingMock(ctx, bucketName, tags) +} + +func (mc minioClientMock) RemoveBucketTagging(ctx context.Context, bucketName string) error { + return minioRemoveBucketTaggingMock(ctx, bucketName) +} + +func minioGetBucketTaggingMock(ctx context.Context, bucketName string) (*tags.Tags, error) { + fmt.Println(ctx) + fmt.Println(bucketName) + retval, _ := tags.NewTags(map[string]string{}, true) + return retval, nil +} + func TestListBucket(t *testing.T) { assert := assert.New(t) adminClient := adminClientMock{} diff --git a/swagger-console.yml b/swagger-console.yml index 7019c9e67..b74ddbb28 100644 --- a/swagger-console.yml +++ b/swagger-console.yml @@ -611,6 +611,30 @@ paths: tags: - UserAPI + /buckets/{bucket_name}/tags: + put: + summary: Put Bucket's tags + operationId: PutBucketTags + parameters: + - name: bucket_name + in: path + required: true + type: string + - name: body + in: body + required: true + schema: + $ref: "#/definitions/putBucketTagsRequest" + responses: + 200: + description: A successful response. + default: + description: Generic error response. + schema: + $ref: "#/definitions/error" + tags: + - UserAPI + /buckets/{name}/set-policy: put: summary: Bucket Set Policy @@ -3512,6 +3536,13 @@ definitions: additionalProperties: type: string + putBucketTagsRequest: + type: object + properties: + tags: + additionalProperties: + type: string + objectRetentionUnit: type: string enum: