Skip to content

Commit a08cc6b

Browse files
authored
chore: runtime params variable refactorings (#6183)
* chore: runtime variable refactoring * chore: deprecated api /orchestrator/plugin/global POST * updated migration number * fix: mark runner failed * minor refactoring * version dep updated * chore: review feedback incorporated * fix: special handling for externalCiArtifact * rbac updated for GetAllGlobalVariables API * updated migration number * fix: updated validatePluginVariable * fix: added default fallback for VariableType * fix: sync cd validation issue * updated vendor files Signed-off-by: Ash-exp <[email protected]> * updated migration script number --------- Signed-off-by: Ash-exp <[email protected]>
1 parent 55b424e commit a08cc6b

File tree

77 files changed

+2074
-855
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+2074
-855
lines changed

688

Whitespace-only changes.

Wire.go

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,6 @@ import (
154154
repository5 "github.com/devtron-labs/devtron/pkg/pipeline/repository"
155155
"github.com/devtron-labs/devtron/pkg/pipeline/types"
156156
"github.com/devtron-labs/devtron/pkg/plugin"
157-
repository6 "github.com/devtron-labs/devtron/pkg/plugin/repository"
158157
"github.com/devtron-labs/devtron/pkg/policyGovernance"
159158
resourceGroup2 "github.com/devtron-labs/devtron/pkg/resourceGroup"
160159
"github.com/devtron-labs/devtron/pkg/resourceQualifiers"
@@ -783,12 +782,7 @@ func InitializeApp() (*App, error) {
783782
// history ends
784783

785784
// plugin starts
786-
repository6.NewGlobalPluginRepository,
787-
wire.Bind(new(repository6.GlobalPluginRepository), new(*repository6.GlobalPluginRepositoryImpl)),
788-
789-
plugin.NewGlobalPluginService,
790-
wire.Bind(new(plugin.GlobalPluginService), new(*plugin.GlobalPluginServiceImpl)),
791-
785+
plugin.WireSet,
792786
restHandler.NewGlobalPluginRestHandler,
793787
wire.Bind(new(restHandler.GlobalPluginRestHandler), new(*restHandler.GlobalPluginRestHandlerImpl)),
794788

api/bean/ConfigMapAndSecret.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package bean
1919
import (
2020
"encoding/json"
2121
"github.com/devtron-labs/devtron/util"
22+
"github.com/devtron-labs/devtron/util/sliceUtil"
2223
)
2324

2425
type ConfigMapRootJson struct {
@@ -64,11 +65,11 @@ func (configSecret ConfigSecretMap) GetDataMap() (map[string]string, error) {
6465
return datamap, err
6566
}
6667
func (configSecretJson ConfigSecretJson) GetDereferencedSecrets() []ConfigSecretMap {
67-
return util.GetDeReferencedArray(configSecretJson.Secrets)
68+
return sliceUtil.GetDeReferencedSlice(configSecretJson.Secrets)
6869
}
6970

7071
func (configSecretJson *ConfigSecretJson) SetReferencedSecrets(secrets []ConfigSecretMap) {
71-
configSecretJson.Secrets = util.GetReferencedArray(secrets)
72+
configSecretJson.Secrets = sliceUtil.GetReferencedSlice(secrets)
7273
}
7374

7475
func GetTransformedDataForSecretRootJsonData(data string, mode util.SecretTransformMode) (string, error) {

api/bean/ValuesOverrideRequest.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ import (
2323
)
2424

2525
type WorkflowType string
26+
27+
func (workflowType WorkflowType) String() string {
28+
return string(workflowType)
29+
}
30+
31+
func NewWorkflowType(workflowType string) WorkflowType {
32+
return WorkflowType(workflowType)
33+
}
34+
2635
type DeploymentConfigurationType string
2736

2837
const (

api/restHandler/GlobalPluginRestHandler.go

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"errors"
2222
"fmt"
2323
"github.com/devtron-labs/devtron/api/restHandler/common"
24+
"github.com/devtron-labs/devtron/internal/sql/repository/helper"
2425
"github.com/devtron-labs/devtron/pkg/auth/authorisation/casbin"
2526
"github.com/devtron-labs/devtron/pkg/auth/user"
2627
"github.com/devtron-labs/devtron/pkg/pipeline"
@@ -37,7 +38,6 @@ import (
3738
)
3839

3940
type GlobalPluginRestHandler interface {
40-
PatchPlugin(w http.ResponseWriter, r *http.Request)
4141
CreatePlugin(w http.ResponseWriter, r *http.Request)
4242

4343
GetAllGlobalVariables(w http.ResponseWriter, r *http.Request)
@@ -75,7 +75,12 @@ type GlobalPluginRestHandlerImpl struct {
7575
userService user.UserService
7676
}
7777

78-
func (handler *GlobalPluginRestHandlerImpl) PatchPlugin(w http.ResponseWriter, r *http.Request) {
78+
// Deprecated: method patchPlugin
79+
// The below API was initially designed to handle the older design of global plugins.
80+
// The API is not yet used in UI.
81+
// The CODE is not yet tested for all the cases.
82+
// TODO: remove this dead code and all the related handling.
83+
func (handler *GlobalPluginRestHandlerImpl) patchPlugin(w http.ResponseWriter, r *http.Request) {
7984
decoder := json.NewDecoder(r.Body)
8085
userId, err := handler.userService.GetLoggedInUser(r)
8186
if userId == 0 || err != nil {
@@ -85,7 +90,7 @@ func (handler *GlobalPluginRestHandlerImpl) PatchPlugin(w http.ResponseWriter, r
8590
var pluginDataDto bean.PluginMetadataDto
8691
err = decoder.Decode(&pluginDataDto)
8792
if err != nil {
88-
handler.logger.Errorw("request err, PatchPlugin", "error", err, "payload", pluginDataDto)
93+
handler.logger.Errorw("request err, patchPlugin", "error", err, "payload", pluginDataDto)
8994
common.WriteJsonResp(w, err, nil, http.StatusBadRequest)
9095
return
9196
}
@@ -105,8 +110,8 @@ func (handler *GlobalPluginRestHandlerImpl) PatchPlugin(w http.ResponseWriter, r
105110
return
106111
}
107112
common.WriteJsonResp(w, nil, pluginData, http.StatusOK)
108-
109113
}
114+
110115
func (handler *GlobalPluginRestHandlerImpl) GetDetailedPluginInfoByPluginId(w http.ResponseWriter, r *http.Request) {
111116
userId, err := handler.userService.GetLoggedInUser(r)
112117
if userId == 0 || err != nil {
@@ -159,7 +164,6 @@ func (handler *GlobalPluginRestHandlerImpl) GetAllDetailedPluginInfo(w http.Resp
159164
}
160165

161166
func (handler *GlobalPluginRestHandlerImpl) GetAllGlobalVariables(w http.ResponseWriter, r *http.Request) {
162-
token := r.Header.Get("token")
163167
appIdQueryParam := r.URL.Query().Get("appId")
164168
appId, err := strconv.Atoi(appIdQueryParam)
165169
if appIdQueryParam == "" || err != nil {
@@ -172,15 +176,24 @@ func (handler *GlobalPluginRestHandlerImpl) GetAllGlobalVariables(w http.Respons
172176
common.WriteJsonResp(w, err, nil, http.StatusBadRequest)
173177
return
174178
}
175-
//using appId for rbac in plugin(global resource), because this data must be visible to person having create permission
176-
//on atleast one app & we can't check this without iterating through every app
177-
//TODO: update plugin as a resource in casbin and make rbac independent of appId
178-
resourceName := handler.enforcerUtil.GetAppRBACName(app.AppName)
179-
ok := handler.enforcerUtil.CheckAppRbacForAppOrJob(token, resourceName, casbin.ActionCreate)
180-
if !ok {
181-
common.WriteJsonResp(w, fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden)
179+
180+
//RBAC START
181+
// TODO: RBAC fix; Mature CheckAppRbacForAppOrJob method to handle based on AppType
182+
// Should be implemented as below
183+
token := r.Header.Get(common.TokenHeaderKey)
184+
object := handler.enforcerUtil.GetAppRBACNameByAppId(appId)
185+
authorised := false
186+
if app.AppType == helper.Job {
187+
authorised = handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionGet, object)
188+
} else if app.AppType == helper.CustomApp {
189+
authorised = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionGet, object)
190+
}
191+
if !authorised {
192+
common.WriteJsonResp(w, common.ErrUnAuthorized, nil, http.StatusForbidden)
182193
return
183194
}
195+
//RBAC END
196+
184197
globalVariables, err := handler.globalPluginService.GetAllGlobalVariables(app.AppType)
185198
if err != nil {
186199
handler.logger.Errorw("error in getting global variable list", "err", err)

api/restHandler/app/pipeline/configure/BuildPipelineRestHandler.go

Lines changed: 59 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import (
2121
"encoding/json"
2222
"errors"
2323
"fmt"
24+
apiBean "github.com/devtron-labs/devtron/api/restHandler/app/pipeline/configure/bean"
2425
"github.com/devtron-labs/devtron/internal/sql/constants"
2526
"github.com/devtron-labs/devtron/pkg/build/artifacts/imageTagging"
2627
bean2 "github.com/devtron-labs/devtron/pkg/build/pipeline/bean"
28+
"github.com/devtron-labs/devtron/util/stringsUtil"
2729
"golang.org/x/exp/maps"
2830
"io"
2931
"net/http"
@@ -47,21 +49,12 @@ import (
4749
bean1 "github.com/devtron-labs/devtron/pkg/pipeline/bean"
4850
"github.com/devtron-labs/devtron/pkg/pipeline/types"
4951
resourceGroup "github.com/devtron-labs/devtron/pkg/resourceGroup"
50-
util2 "github.com/devtron-labs/devtron/util"
5152
"github.com/devtron-labs/devtron/util/response"
5253
"github.com/go-pg/pg"
5354
"github.com/gorilla/mux"
5455
"go.opentelemetry.io/otel"
5556
)
5657

57-
const GIT_MATERIAL_DELETE_SUCCESS_RESP = "Git material deleted successfully."
58-
59-
type BuildHistoryResponse struct {
60-
HideImageTaggingHardDelete bool `json:"hideImageTaggingHardDelete"`
61-
TagsEditable bool `json:"tagsEditable"`
62-
AppReleaseTagNames []string `json:"appReleaseTagNames"` //unique list of tags exists in the app
63-
CiWorkflows []types.WorkflowResponse `json:"ciWorkflows"`
64-
}
6558
type DevtronAppBuildRestHandler interface {
6659
CreateCiConfig(w http.ResponseWriter, r *http.Request)
6760
UpdateCiTemplate(w http.ResponseWriter, r *http.Request)
@@ -648,72 +641,48 @@ func (handler *PipelineConfigRestHandlerImpl) GetExternalCiById(w http.ResponseW
648641
common.WriteJsonResp(w, err, ciConf, http.StatusOK)
649642
}
650643

651-
func (handler *PipelineConfigRestHandlerImpl) TriggerCiPipeline(w http.ResponseWriter, r *http.Request) {
652-
userId, err := handler.userAuthService.GetLoggedInUser(r)
653-
if userId == 0 || err != nil {
654-
common.WriteJsonResp(w, err, "Unauthorized User", http.StatusUnauthorized)
655-
return
656-
}
657-
decoder := json.NewDecoder(r.Body)
658-
var ciTriggerRequest bean.CiTriggerRequest
659-
err = decoder.Decode(&ciTriggerRequest)
644+
func (handler *PipelineConfigRestHandlerImpl) validateCiTriggerRBAC(token string, ciPipelineId, triggerEnvironmentId int) error {
645+
// RBAC STARTS
646+
// checking if user has trigger access on app, if not will be forbidden to trigger independent of number of cd cdPipelines
647+
ciPipeline, err := handler.ciPipelineRepository.FindById(ciPipelineId)
660648
if err != nil {
661-
handler.Logger.Errorw("request err, TriggerCiPipeline", "err", err, "payload", ciTriggerRequest)
662-
common.WriteJsonResp(w, err, nil, http.StatusBadRequest)
663-
return
649+
handler.Logger.Errorw("err in finding ci pipeline, TriggerCiPipeline", "err", err, "ciPipelineId", ciPipelineId)
650+
errMsg := fmt.Sprintf("error in finding ci pipeline for id '%d'", ciPipelineId)
651+
return util.NewApiError(http.StatusBadRequest, errMsg, errMsg)
664652
}
665-
if !handler.validForMultiMaterial(ciTriggerRequest) {
666-
handler.Logger.Errorw("invalid req, commit hash not present for multi-git", "payload", ciTriggerRequest)
667-
common.WriteJsonResp(w, errors.New("invalid req, commit hash not present for multi-git"),
668-
nil, http.StatusBadRequest)
669-
}
670-
ciTriggerRequest.TriggeredBy = userId
671-
token := r.Header.Get("token")
672-
673-
handler.Logger.Infow("request payload, TriggerCiPipeline", "payload", ciTriggerRequest)
674-
675-
//RBAC STARTS
676-
//checking if user has trigger access on app, if not will be forbidden to trigger independent of number of cd cdPipelines
677-
ciPipeline, err := handler.ciPipelineRepository.FindById(ciTriggerRequest.PipelineId)
678-
if err != nil {
679-
handler.Logger.Errorw("err in finding ci pipeline, TriggerCiPipeline", "err", err, "ciPipelineId", ciTriggerRequest.PipelineId)
680-
common.WriteJsonResp(w, err, nil, http.StatusInternalServerError)
681-
return
682-
}
683-
appWorkflowMapping, err := handler.appWorkflowService.FindAppWorkflowByCiPipelineId(ciTriggerRequest.PipelineId)
653+
appWorkflowMapping, err := handler.appWorkflowService.FindAppWorkflowByCiPipelineId(ciPipelineId)
684654
if err != nil {
685-
handler.Logger.Errorw("err in finding appWorkflowMapping, TriggerCiPipeline", "err", err, "ciPipelineId", ciTriggerRequest.PipelineId)
686-
common.WriteJsonResp(w, err, nil, http.StatusInternalServerError)
687-
return
655+
handler.Logger.Errorw("err in finding appWorkflowMapping, TriggerCiPipeline", "err", err, "ciPipelineId", ciPipelineId)
656+
errMsg := fmt.Sprintf("error in finding appWorkflowMapping for ciPipelineId '%d'", ciPipelineId)
657+
return util.NewApiError(http.StatusBadRequest, errMsg, errMsg)
688658
}
689659
workflowName := ""
690660
if len(appWorkflowMapping) > 0 {
691661
workflowName = appWorkflowMapping[0].AppWorkflow.Name
692662
}
693663
// This is being done for jobs, jobs execute in default-env (devtron-ci) namespace by default. so considering DefaultCiNamespace as env for rbac enforcement
694664
envName := ""
695-
if ciTriggerRequest.EnvironmentId == 0 {
665+
if triggerEnvironmentId == 0 {
696666
envName = pipeline.DefaultCiWorkflowNamespace
697667
}
698668
appObject := handler.enforcerUtil.GetAppRBACNameByAppId(ciPipeline.AppId)
699-
workflowObject := handler.enforcerUtil.GetWorkflowRBACByCiPipelineId(ciTriggerRequest.PipelineId, workflowName)
700-
triggerObject := handler.enforcerUtil.GetTeamEnvRBACNameByCiPipelineIdAndEnvIdOrName(ciTriggerRequest.PipelineId, ciTriggerRequest.EnvironmentId, envName)
669+
workflowObject := handler.enforcerUtil.GetWorkflowRBACByCiPipelineId(ciPipelineId, workflowName)
670+
triggerObject := handler.enforcerUtil.GetTeamEnvRBACNameByCiPipelineIdAndEnvIdOrName(ciPipelineId, triggerEnvironmentId, envName)
701671
appRbacOk := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionTrigger, appObject)
702672
if !appRbacOk {
703673
appRbacOk = handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionTrigger, appObject) && handler.enforcer.Enforce(token, casbin.ResourceWorkflow, casbin.ActionTrigger, workflowObject) && handler.enforcer.Enforce(token, casbin.ResourceJobsEnv, casbin.ActionTrigger, triggerObject)
704674
}
705675

706676
if !appRbacOk {
707677
handler.Logger.Debug(fmt.Errorf("unauthorized user"), "Unauthorized User", http.StatusForbidden)
708-
common.WriteJsonResp(w, err, "Unauthorized User", http.StatusForbidden)
709-
return
678+
return util.NewApiError(http.StatusForbidden, common.UnAuthorisedUser, common.UnAuthorisedUser)
710679
}
711-
//checking rbac for cd cdPipelines
712-
cdPipelines, err := handler.pipelineRepository.FindByCiPipelineId(ciTriggerRequest.PipelineId)
680+
// checking rbac for cd cdPipelines
681+
cdPipelines, err := handler.pipelineRepository.FindByCiPipelineId(ciPipelineId)
713682
if err != nil {
714-
handler.Logger.Errorw("error in finding ccd cdPipelines by ciPipelineId", "err", err, "ciPipelineId", ciTriggerRequest.PipelineId)
715-
common.WriteJsonResp(w, err, nil, http.StatusInternalServerError)
716-
return
683+
handler.Logger.Errorw("error in finding ccd cdPipelines by ciPipelineId", "err", err, "ciPipelineId", ciPipelineId)
684+
errMsg := fmt.Sprintf("error in finding cd cdPipelines for ciPipelineId '%d'", ciPipelineId)
685+
return util.NewApiError(http.StatusBadRequest, errMsg, errMsg)
717686
}
718687
cdPipelineRbacObjects := make([]string, len(cdPipelines))
719688
rbacObjectCdTriggerTypeMap := make(map[string]pipelineConfig.TriggerType, len(cdPipelines))
@@ -729,21 +698,50 @@ func (handler *PipelineConfigRestHandlerImpl) TriggerCiPipeline(w http.ResponseW
729698
envRbacResultMap := handler.enforcer.EnforceInBatch(token, casbin.ResourceEnvironment, casbin.ActionTrigger, cdPipelineRbacObjects)
730699
for rbacObject, rbacResultOk := range envRbacResultMap {
731700
if rbacObjectCdTriggerTypeMap[rbacObject] == pipelineConfig.TRIGGER_TYPE_AUTOMATIC && !rbacResultOk {
732-
common.WriteJsonResp(w, err, "Unauthorized User", http.StatusForbidden)
733-
return
701+
return util.NewApiError(http.StatusForbidden, common.UnAuthorisedUser, common.UnAuthorisedUser)
734702
}
735703
if rbacResultOk { //this flow will come if pipeline is automatic and has access or if pipeline is manual,
736704
// by which we can ensure if there are no automatic pipelines then atleast access on one manual is present
737705
hasAnyEnvTriggerAccess = true
738706
}
739707
}
740708
if !hasAnyEnvTriggerAccess {
741-
common.WriteJsonResp(w, err, "Unauthorized User", http.StatusForbidden)
742-
return
709+
return util.NewApiError(http.StatusForbidden, common.UnAuthorisedUser, common.UnAuthorisedUser)
743710
}
744711
}
712+
// RBAC ENDS
713+
return nil
714+
}
745715

746-
//RBAC ENDS
716+
func (handler *PipelineConfigRestHandlerImpl) TriggerCiPipeline(w http.ResponseWriter, r *http.Request) {
717+
userId, err := handler.userAuthService.GetLoggedInUser(r)
718+
if userId == 0 || err != nil {
719+
common.WriteJsonResp(w, err, "Unauthorized User", http.StatusUnauthorized)
720+
return
721+
}
722+
decoder := json.NewDecoder(r.Body)
723+
var ciTriggerRequest bean.CiTriggerRequest
724+
err = decoder.Decode(&ciTriggerRequest)
725+
if err != nil {
726+
handler.Logger.Errorw("request err, TriggerCiPipeline", "err", err, "payload", ciTriggerRequest)
727+
common.WriteJsonResp(w, err, nil, http.StatusBadRequest)
728+
return
729+
}
730+
token := r.Header.Get("token")
731+
// RBAC block starts
732+
err = handler.validateCiTriggerRBAC(token, ciTriggerRequest.PipelineId, ciTriggerRequest.EnvironmentId)
733+
if err != nil {
734+
common.WriteJsonResp(w, err, nil, http.StatusInternalServerError)
735+
return
736+
}
737+
// RBAC block ends
738+
if !handler.validForMultiMaterial(ciTriggerRequest) {
739+
handler.Logger.Errorw("invalid req, commit hash not present for multi-git", "payload", ciTriggerRequest)
740+
common.WriteJsonResp(w, errors.New("invalid req, commit hash not present for multi-git"),
741+
nil, http.StatusBadRequest)
742+
}
743+
ciTriggerRequest.TriggeredBy = userId
744+
handler.Logger.Infow("request payload, TriggerCiPipeline", "payload", ciTriggerRequest)
747745
response := make(map[string]string)
748746
resp, err := handler.ciHandler.HandleCIManual(ciTriggerRequest)
749747
if errors.Is(err, bean1.ErrImagePathInUse) {
@@ -916,7 +914,7 @@ func (handler *PipelineConfigRestHandlerImpl) GetCiPipelineMin(w http.ResponseWr
916914
envIdsString := v.Get("envIds")
917915
envIds := make([]int, 0)
918916
if len(envIdsString) > 0 {
919-
envIds, err = util2.SplitCommaSeparatedIntValues(envIdsString)
917+
envIds, err = stringsUtil.SplitCommaSeparatedIntValues(envIdsString)
920918
if err != nil {
921919
common.WriteJsonResp(w, err, "please provide valid envIds", http.StatusBadRequest)
922920
return
@@ -1103,7 +1101,7 @@ func (handler *PipelineConfigRestHandlerImpl) GetBuildHistory(w http.ResponseWri
11031101
//RBAC for edit tag access , user should have build permission in current ci-pipeline
11041102
triggerAccess := handler.enforcer.Enforce(token, casbin.ResourceApplications, casbin.ActionTrigger, object) || handler.enforcer.Enforce(token, casbin.ResourceJobs, casbin.ActionTrigger, object)
11051103
//RBAC
1106-
resp := BuildHistoryResponse{}
1104+
resp := apiBean.BuildHistoryResponse{}
11071105
workflowsResp, err := handler.ciHandler.GetBuildHistory(pipelineId, ciPipeline.AppId, offset, limit)
11081106
resp.CiWorkflows = workflowsResp
11091107
if err != nil {
@@ -1501,7 +1499,7 @@ func (handler *PipelineConfigRestHandlerImpl) DeleteMaterial(w http.ResponseWrit
15011499
common.WriteJsonResp(w, err, nil, http.StatusInternalServerError)
15021500
return
15031501
}
1504-
common.WriteJsonResp(w, err, GIT_MATERIAL_DELETE_SUCCESS_RESP, http.StatusOK)
1502+
common.WriteJsonResp(w, err, apiBean.GIT_MATERIAL_DELETE_SUCCESS_RESP, http.StatusOK)
15051503
}
15061504

15071505
func (handler *PipelineConfigRestHandlerImpl) HandleWorkflowWebhook(w http.ResponseWriter, r *http.Request) {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package bean
2+
3+
import "github.com/devtron-labs/devtron/pkg/pipeline/types"
4+
5+
const GIT_MATERIAL_DELETE_SUCCESS_RESP = "Git material deleted successfully."
6+
7+
type BuildHistoryResponse struct {
8+
HideImageTaggingHardDelete bool `json:"hideImageTaggingHardDelete"`
9+
TagsEditable bool `json:"tagsEditable"`
10+
AppReleaseTagNames []string `json:"appReleaseTagNames"` //unique list of tags exists in the app
11+
CiWorkflows []types.WorkflowResponse `json:"ciWorkflows"`
12+
}

0 commit comments

Comments
 (0)