diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 8017d8add..37ad96ce7 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -27,6 +27,7 @@ type Store struct { RegistryService *RegistryService DockerHubService *DockerHubService StackService *StackService + TagService *TagService db *bolt.DB checkForDataMigration bool @@ -45,6 +46,7 @@ const ( registryBucketName = "registries" dockerhubBucketName = "dockerhub" stackBucketName = "stacks" + tagBucketName = "tags" ) // NewStore initializes a new Store and the associated services @@ -62,6 +64,7 @@ func NewStore(storePath string) (*Store, error) { RegistryService: &RegistryService{}, DockerHubService: &DockerHubService{}, StackService: &StackService{}, + TagService: &TagService{}, } store.UserService.store = store store.TeamService.store = store @@ -74,6 +77,7 @@ func NewStore(storePath string) (*Store, error) { store.RegistryService.store = store store.DockerHubService.store = store store.StackService.store = store + store.TagService.store = store _, err := os.Stat(storePath + "/" + databaseFileName) if err != nil && os.IsNotExist(err) { @@ -99,7 +103,7 @@ func (store *Store) Open() error { bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName, - registryBucketName, dockerhubBucketName, stackBucketName} + registryBucketName, dockerhubBucketName, stackBucketName, tagBucketName} return db.Update(func(tx *bolt.Tx) error { @@ -128,6 +132,7 @@ func (store *Store) Init() error { Labels: []portainer.Pair{}, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, + Tags: []string{}, } return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup) diff --git a/api/bolt/internal/internal.go b/api/bolt/internal/internal.go index b247268ee..7ed0d72be 100644 --- a/api/bolt/internal/internal.go +++ b/api/bolt/internal/internal.go @@ -107,6 +107,16 @@ func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error { return json.Unmarshal(data, settings) } +// MarshalTag encodes a Tag object to binary format. +func MarshalTag(tag *portainer.Tag) ([]byte, error) { + return json.Marshal(tag) +} + +// UnmarshalTag decodes a Tag object from a binary data. +func UnmarshalTag(data []byte, tag *portainer.Tag) error { + return json.Unmarshal(data, tag) +} + // Itob returns an 8-byte big endian representation of v. // This function is typically used for encoding integer IDs to byte slices // so that they can be used as BoltDB keys. diff --git a/api/bolt/migrate_dbversion11.go b/api/bolt/migrate_dbversion11.go new file mode 100644 index 000000000..a16cc093b --- /dev/null +++ b/api/bolt/migrate_dbversion11.go @@ -0,0 +1,37 @@ +package bolt + +func (m *Migrator) updateEndpointsToVersion12() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.Tags = []string{} + + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} + +func (m *Migrator) updateEndpointGroupsToVersion12() error { + legacyEndpointGroups, err := m.EndpointGroupService.EndpointGroups() + if err != nil { + return err + } + + for _, group := range legacyEndpointGroups { + group.Tags = []string{} + + err = m.EndpointGroupService.UpdateEndpointGroup(group.ID, &group) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index 48242adaf..8f914a7b4 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -6,6 +6,7 @@ import "github.com/portainer/portainer" type Migrator struct { UserService *UserService EndpointService *EndpointService + EndpointGroupService *EndpointGroupService ResourceControlService *ResourceControlService SettingsService *SettingsService VersionService *VersionService @@ -18,6 +19,7 @@ func NewMigrator(store *Store, version int) *Migrator { return &Migrator{ UserService: store.UserService, EndpointService: store.EndpointService, + EndpointGroupService: store.EndpointGroupService, ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, VersionService: store.VersionService, @@ -120,6 +122,18 @@ func (m *Migrator) Migrate() error { } } + if m.CurrentDBVersion < 12 { + err := m.updateEndpointsToVersion12() + if err != nil { + return err + } + + err = m.updateEndpointGroupsToVersion12() + if err != nil { + return err + } + } + err := m.VersionService.StoreDBVersion(portainer.DBVersion) if err != nil { return err diff --git a/api/bolt/tag_service.go b/api/bolt/tag_service.go new file mode 100644 index 000000000..0c57ace8f --- /dev/null +++ b/api/bolt/tag_service.go @@ -0,0 +1,71 @@ +package bolt + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +// TagService represents a service for managing tags. +type TagService struct { + store *Store +} + +// Tags return an array containing all the tags. +func (service *TagService) Tags() ([]portainer.Tag, error) { + var tags = make([]portainer.Tag, 0) + err := service.store.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(tagBucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var tag portainer.Tag + err := internal.UnmarshalTag(v, &tag) + if err != nil { + return err + } + tags = append(tags, tag) + } + + return nil + }) + if err != nil { + return nil, err + } + + return tags, nil +} + +// CreateTag creates a new tag. +func (service *TagService) CreateTag(tag *portainer.Tag) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(tagBucketName)) + + id, _ := bucket.NextSequence() + tag.ID = portainer.TagID(id) + + data, err := internal.MarshalTag(tag) + if err != nil { + return err + } + + err = bucket.Put(internal.Itob(int(tag.ID)), data) + if err != nil { + return err + } + return nil + }) +} + +// DeleteTag deletes a tag. +func (service *TagService) DeleteTag(ID portainer.TagID) error { + return service.store.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(tagBucketName)) + err := bucket.Delete(internal.Itob(int(ID))) + if err != nil { + return err + } + return nil + }) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 0c13e118f..4f37e7846 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -236,6 +236,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: []string{}, } if strings.HasPrefix(endpoint.URL, "tcp://") { @@ -274,6 +275,7 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: []string{}, } return endpointService.CreateEndpoint(endpoint) @@ -401,6 +403,7 @@ func main() { RegistryService: store.RegistryService, DockerHubService: store.DockerHubService, StackService: store.StackService, + TagService: store.TagService, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, CryptoService: cryptoService, diff --git a/api/errors.go b/api/errors.go index c8f534f2c..4c8823891 100644 --- a/api/errors.go +++ b/api/errors.go @@ -68,6 +68,11 @@ const ( ErrStackNotExternal = Error("Not an external stack") ) +// Tag errors +const ( + ErrTagAlreadyExists = Error("A tag already exists with this name") +) + // Endpoint extensions error const ( ErrEndpointExtensionNotSupported = Error("This extension is not supported") diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index 834122f08..e2d7bd0c0 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -13,14 +13,17 @@ import ( type endpointGroupCreatePayload struct { Name string Description string - Labels []portainer.Pair AssociatedEndpoints []portainer.EndpointID + Tags []string } func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return portainer.Error("Invalid endpoint group name") } + if payload.Tags == nil { + payload.Tags = []string{} + } return nil } @@ -35,9 +38,9 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque endpointGroup := &portainer.EndpointGroup{ Name: payload.Name, Description: payload.Description, - Labels: payload.Labels, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, + Tags: payload.Tags, } err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup) diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 2f23c0f66..237e5d9a6 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -12,8 +12,8 @@ import ( type endpointGroupUpdatePayload struct { Name string Description string - Labels []portainer.Pair AssociatedEndpoints []portainer.EndpointID + Tags []string } func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { @@ -48,7 +48,9 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque endpointGroup.Description = payload.Description } - endpointGroup.Labels = payload.Labels + if payload.Tags != nil { + endpointGroup.Tags = payload.Tags + } err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 38d2cfd47..7827068cb 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -28,6 +28,7 @@ type endpointCreatePayload struct { AzureApplicationID string AzureTenantID string AzureAuthenticationKey string + Tags []string } func (payload *endpointCreatePayload) Validate(r *http.Request) error { @@ -49,6 +50,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { } payload.GroupID = groupID + var tags []string + err = request.RetrieveMultiPartFormJSONValue(r, "Tags", &tags, true) + if err != nil { + return portainer.Error("Invalid Tags parameter") + } + payload.Tags = tags + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) payload.TLS = useTLS @@ -168,6 +176,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, AzureCredentials: credentials, + Tags: payload.Tags, } err = handler.EndpointService.CreateEndpoint(endpoint) @@ -203,6 +212,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: payload.Tags, } err := handler.EndpointService.CreateEndpoint(endpoint) @@ -242,6 +252,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, + Tags: payload.Tags, } err = handler.EndpointService.CreateEndpoint(endpoint) diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index a65e9872b..7532c0450 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -22,6 +22,7 @@ type endpointUpdatePayload struct { AzureApplicationID string AzureTenantID string AzureAuthenticationKey string + Tags []string } func (payload *endpointUpdatePayload) Validate(r *http.Request) error { @@ -68,6 +69,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID) } + if payload.Tags != nil { + endpoint.Tags = payload.Tags + } + if endpoint.Type == portainer.AzureEnvironment { credentials := endpoint.AzureCredentials if payload.AzureApplicationID != "" { diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 84d01ddc5..dd15e1212 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -15,6 +15,7 @@ import ( "github.com/portainer/portainer/http/handler/settings" "github.com/portainer/portainer/http/handler/stacks" "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/tags" "github.com/portainer/portainer/http/handler/teammemberships" "github.com/portainer/portainer/http/handler/teams" "github.com/portainer/portainer/http/handler/templates" @@ -37,16 +38,13 @@ type Handler struct { SettingsHandler *settings.Handler StackHandler *stacks.Handler StatusHandler *status.Handler + TagHandler *tags.Handler TeamMembershipHandler *teammemberships.Handler TeamHandler *teams.Handler TemplatesHandler *templates.Handler UploadHandler *upload.Handler UserHandler *users.Handler WebSocketHandler *websocket.Handler - - // StoridgeHandler *extensions.StoridgeHandler - // AzureHandler *azure.Handler - // DockerHandler *docker.Handler } // ServeHTTP delegates a request to the appropriate subhandler. @@ -79,6 +77,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/status"): http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/tags"): + http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/upload"): diff --git a/api/http/handler/tags/handler.go b/api/http/handler/tags/handler.go new file mode 100644 index 000000000..a700f7c3e --- /dev/null +++ b/api/http/handler/tags/handler.go @@ -0,0 +1,31 @@ +package tags + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle tag operations. +type Handler struct { + *mux.Router + TagService portainer.TagService +} + +// NewHandler creates a handler to manage tag operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/tags", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost) + h.Handle("/tags", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet) + h.Handle("/tags/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete) + + return h +} diff --git a/api/http/handler/tags/tag_create.go b/api/http/handler/tags/tag_create.go new file mode 100644 index 000000000..f75c050b9 --- /dev/null +++ b/api/http/handler/tags/tag_create.go @@ -0,0 +1,53 @@ +package tags + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +type tagCreatePayload struct { + Name string +} + +func (payload *tagCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return portainer.Error("Invalid tag name") + } + return nil +} + +// POST request on /api/tags +func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload tagCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + tags, err := handler.TagService.Tags() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} + } + + for _, tag := range tags { + if tag.Name == payload.Name { + return &httperror.HandlerError{http.StatusConflict, "This name is already associated to a tag", portainer.ErrTagAlreadyExists} + } + } + + tag := &portainer.Tag{ + Name: payload.Name, + } + + err = handler.TagService.CreateTag(tag) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the tag inside the database", err} + } + + return response.JSON(w, tag) +} diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go new file mode 100644 index 000000000..b1b4fe867 --- /dev/null +++ b/api/http/handler/tags/tag_delete.go @@ -0,0 +1,25 @@ +package tags + +import ( + "net/http" + + "github.com/portainer/portainer" + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/request" + "github.com/portainer/portainer/http/response" +) + +// DELETE request on /api/tags/:name +func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + id, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid tag identifier route variable", err} + } + + err = handler.TagService.DeleteTag(portainer.TagID(id)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/tags/tag_list.go b/api/http/handler/tags/tag_list.go new file mode 100644 index 000000000..b572add68 --- /dev/null +++ b/api/http/handler/tags/tag_list.go @@ -0,0 +1,18 @@ +package tags + +import ( + "net/http" + + httperror "github.com/portainer/portainer/http/error" + "github.com/portainer/portainer/http/response" +) + +// GET request on /api/tags +func (handler *Handler) tagList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tags, err := handler.TagService.Tags() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err} + } + + return response.JSON(w, tags) +} diff --git a/api/http/server.go b/api/http/server.go index f0b1cdbda..9c476c74d 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -16,6 +16,7 @@ import ( "github.com/portainer/portainer/http/handler/settings" "github.com/portainer/portainer/http/handler/stacks" "github.com/portainer/portainer/http/handler/status" + "github.com/portainer/portainer/http/handler/tags" "github.com/portainer/portainer/http/handler/teammemberships" "github.com/portainer/portainer/http/handler/teams" "github.com/portainer/portainer/http/handler/templates" @@ -36,24 +37,25 @@ type Server struct { AuthDisabled bool EndpointManagement bool Status *portainer.Status - UserService portainer.UserService - TeamService portainer.TeamService - TeamMembershipService portainer.TeamMembershipService + ComposeStackManager portainer.ComposeStackManager + CryptoService portainer.CryptoService + SignatureService portainer.DigitalSignatureService + DockerHubService portainer.DockerHubService EndpointService portainer.EndpointService EndpointGroupService portainer.EndpointGroupService + FileService portainer.FileService + GitService portainer.GitService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService SettingsService portainer.SettingsService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - FileService portainer.FileService - RegistryService portainer.RegistryService - DockerHubService portainer.DockerHubService StackService portainer.StackService SwarmStackManager portainer.SwarmStackManager - ComposeStackManager portainer.ComposeStackManager - LDAPService portainer.LDAPService - GitService portainer.GitService - SignatureService portainer.DigitalSignatureService + TagService portainer.TagService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService + UserService portainer.UserService Handler *handler.Handler SSL bool SSLCert string @@ -126,6 +128,9 @@ func (server *Server) Start() error { stackHandler.RegistryService = server.RegistryService stackHandler.DockerHubService = server.DockerHubService + var tagHandler = tags.NewHandler(requestBouncer) + tagHandler.TagService = server.TagService + var teamHandler = teams.NewHandler(requestBouncer) teamHandler.TeamService = server.TeamService teamHandler.TeamMembershipService = server.TeamMembershipService @@ -164,6 +169,7 @@ func (server *Server) Start() error { SettingsHandler: settingsHandler, StatusHandler: statusHandler, StackHandler: stackHandler, + TagHandler: tagHandler, TeamHandler: teamHandler, TeamMembershipHandler: teamMembershipHandler, TemplatesHandler: templatesHandler, diff --git a/api/portainer.go b/api/portainer.go index 5053cfccd..bfe84a213 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -190,6 +190,7 @@ type ( AuthorizedTeams []TeamID `json:"AuthorizedTeams"` Extensions []EndpointExtension `json:"Extensions"` AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` + Tags []string `json:"Tags"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -217,7 +218,10 @@ type ( Description string `json:"Description"` AuthorizedUsers []UserID `json:"AuthorizedUsers"` AuthorizedTeams []TeamID `json:"AuthorizedTeams"` - Labels []Pair `json:"Labels"` + Tags []string `json:"Tags"` + + // Deprecated fields + Labels []Pair `json:"Labels"` } // EndpointExtension represents a extension associated to an endpoint. @@ -264,6 +268,15 @@ type ( AccessLevel ResourceAccessLevel `json:"AccessLevel"` } + // TagID represents a tag identifier. + TagID int + + // Tag represents a tag that can be associated to a resource. + Tag struct { + ID TagID + Name string `json:"Name"` + } + // ResourceAccessLevel represents the level of control associated to a resource. ResourceAccessLevel int @@ -390,6 +403,13 @@ type ( DeleteResourceControl(ID ResourceControlID) error } + // TagService represents a service for managing tag data. + TagService interface { + Tags() ([]Tag, error) + CreateTag(tag *Tag) error + DeleteTag(ID TagID) error + } + // CryptoService represents a service for encrypting/hashing data. CryptoService interface { Hash(data string) (string, error) @@ -463,7 +483,7 @@ const ( // APIVersion is the version number of the Portainer API. APIVersion = "1.17.1-dev" // DBVersion is the version number of the Portainer database. - DBVersion = 11 + DBVersion = 12 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" // PortainerAgentHeader represents the name of the header available in any agent response diff --git a/api/swagger.yaml b/api/swagger.yaml index 13277649e..224b9ecf1 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -81,6 +81,8 @@ tags: description: "Manage Docker stacks" - name: "users" description: "Manage users" +- name: "tags" + description: "Manage tags" - name: "teams" description: "Manage teams" - name: "team_memberships" @@ -1958,6 +1960,99 @@ paths: schema: $ref: "#/definitions/GenericError" + /tags: + get: + tags: + - "tags" + summary: "List tags" + description: | + List tags. + **Access policy**: administrator + operationId: "TagList" + produces: + - "application/json" + parameters: [] + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/TagListResponse" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "tags" + summary: "Create a new tag" + description: | + Create a new tag. + **Access policy**: administrator + operationId: "TagCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - in: "body" + name: "body" + description: "Tag details" + required: true + schema: + $ref: "#/definitions/TagCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Tag" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 409: + description: "Conflict" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "A tag with the specified name already exists" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /tags/{id}: + delete: + tags: + - "tags" + summary: "Remove a tag" + description: | + Remove a tag. + **Access policy**: administrator + operationId: "TagDelete" + parameters: + - name: "id" + in: "path" + description: "Tag identifier" + required: true + type: "integer" + responses: + 204: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /teams: get: tags: @@ -2411,6 +2506,17 @@ securityDefinitions: name: "Authorization" in: "header" definitions: + Tag: + type: "object" + properties: + Id: + type: "integer" + example: 1 + description: "Tag identifier" + Name: + type: "string" + example: "org/acme" + description: "Tag name" Team: type: "object" properties: @@ -3334,6 +3440,19 @@ definitions: type: "boolean" example: true description: "Is the password valid" + TagListResponse: + type: "array" + items: + $ref: "#/definitions/Tag" + TagCreateRequest: + type: "object" + required: + - "Name" + properties: + Name: + type: "string" + example: "org/acme" + description: "Name" TeamCreateRequest: type: "object" required: diff --git a/app/constants.js b/app/constants.js index a51ce323e..c4f911034 100644 --- a/app/constants.js +++ b/app/constants.js @@ -9,6 +9,7 @@ angular.module('portainer') .constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') .constant('API_ENDPOINT_USERS', 'api/users') +.constant('API_ENDPOINT_TAGS', 'api/tags') .constant('API_ENDPOINT_TEAMS', 'api/teams') .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships') .constant('API_ENDPOINT_TEMPLATES', 'api/templates') diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 701a2bb8e..6109cc422 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -296,6 +296,17 @@ angular.module('portainer.app', []) } }; + var tags = { + name: 'portainer.tags', + url: '/tags', + views: { + 'content@': { + templateUrl: 'app/portainer/views/tags/tags.html', + controller: 'TagsController' + } + } + }; + var users = { name: 'portainer.users', url: '/users', @@ -366,6 +377,7 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(stack); $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(support); + $stateRegistryProvider.register(tags); $stateRegistryProvider.register(users); $stateRegistryProvider.register(user); $stateRegistryProvider.register(teams); diff --git a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html new file mode 100644 index 000000000..29613f496 --- /dev/null +++ b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html @@ -0,0 +1,84 @@ +
+ + + + + + Name + + + + | +
---|
+ + + + + {{ item.Name }} + | +
Loading... | +
No tag available. | +