diff --git a/.dredd/hooks/capabilities.go b/.dredd/hooks/capabilities.go index 9a606294..7b98c292 100644 --- a/.dredd/hooks/capabilities.go +++ b/.dredd/hooks/capabilities.go @@ -136,6 +136,7 @@ func resolveCapability(caps []string, resolved []string, uid string) { AllowOverrideArgsInTask: false, Description: &desc, ViewID: &view.ID, + App: db.TemplateAnsible, }) printError(err) diff --git a/api-docs.yml b/api-docs.yml index 18a6f4fb..4aa304e5 100644 --- a/api-docs.yml +++ b/api-docs.yml @@ -703,6 +703,9 @@ definitions: example: '' suppress_success_alerts: type: boolean + app: + type: string + example: ansible survey_vars: type: array items: diff --git a/api/apps.go b/api/apps.go new file mode 100644 index 00000000..2c349f05 --- /dev/null +++ b/api/apps.go @@ -0,0 +1,210 @@ +package api + +import ( + "errors" + "fmt" + "github.com/ansible-semaphore/semaphore/api/helpers" + "github.com/ansible-semaphore/semaphore/db" + "github.com/ansible-semaphore/semaphore/util" + "github.com/gorilla/context" + "net/http" + "reflect" + "strings" +) + +func structToFlatMap(obj interface{}) map[string]interface{} { + result := make(map[string]interface{}) + val := reflect.ValueOf(obj) + typ := reflect.TypeOf(obj) + + if typ.Kind() == reflect.Ptr { + val = val.Elem() + typ = typ.Elem() + } + + if typ.Kind() != reflect.Struct { + return result + } + + // Iterate over the struct fields + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + jsonTag := fieldType.Tag.Get("json") + + // Use the json tag if it is set, otherwise use the field name + fieldName := jsonTag + if fieldName == "" || fieldName == "-" { + fieldName = fieldType.Name + } else { + // Handle the case where the json tag might have options like `json:"name,omitempty"` + fieldName = strings.Split(fieldName, ",")[0] + } + + // Check if the field is a struct itself + if field.Kind() == reflect.Struct { + // Convert nested struct to map + nestedMap := structToFlatMap(field.Interface()) + // Add nested map to result with a prefixed key + for k, v := range nestedMap { + result[fieldName+"."+k] = v + } + } else { + result[fieldName] = field.Interface() + } + } + + return result +} + +func validateAppID(str string) error { + return nil +} + +func appMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appID, err := helpers.GetStrParam("app_id", w, r) + if err != nil { + helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) + } + + if err := validateAppID(appID); err != nil { + helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) + return + } + + context.Set(r, "app_id", appID) + next.ServeHTTP(w, r) + }) +} + +func getApps(w http.ResponseWriter, r *http.Request) { + defaultApps := map[string]util.App{ + string(db.TemplateAnsible): {}, + string(db.TemplateTerraform): {}, + string(db.TemplateTofu): {}, + string(db.TemplateBash): {}, + string(db.TemplatePowerShell): {}, + string(db.TemplatePython): {}, + } + + for k, a := range util.Config.Apps { + defaultApps[k] = a + } + + type app struct { + ID string `json:"id"` + Title string `json:"title"` + Icon string `json:"icon"` + Color string `json:"color"` + DarkColor string `json:"dark_color"` + Active bool `json:"active"` + } + + apps := make([]app, 0) + + for k, a := range defaultApps { + + apps = append(apps, app{ + ID: k, + Title: a.Title, + Icon: a.Icon, + Color: a.Color, + DarkColor: a.DarkColor, + Active: a.Active, + }) + } + + helpers.WriteJSON(w, http.StatusOK, apps) +} + +func getApp(w http.ResponseWriter, r *http.Request) { + appID := context.Get(r, "app_id").(string) + + app, ok := util.Config.Apps[appID] + if !ok { + helpers.WriteErrorStatus(w, "app not found", http.StatusNotFound) + return + } + + helpers.WriteJSON(w, http.StatusOK, app) +} + +func deleteApp(w http.ResponseWriter, r *http.Request) { + appID := context.Get(r, "app_id").(string) + + store := helpers.Store(r) + + err := store.DeleteOptions("apps." + appID) + if err != nil && !errors.Is(err, db.ErrNotFound) { + helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) + return + } + + delete(util.Config.Apps, appID) + + w.WriteHeader(http.StatusNoContent) +} + +func setAppOption(store db.Store, appID string, field string, val interface{}) error { + key := "apps." + appID + "." + field + + v := fmt.Sprintf("%v", val) + + if err := store.SetOption(key, v); err != nil { + return err + } + + opts := make(map[string]string) + opts[key] = v + + options := db.ConvertFlatToNested(opts) + + _ = db.AssignMapToStruct(options, util.Config) + + return nil +} + +func setApp(w http.ResponseWriter, r *http.Request) { + appID := context.Get(r, "app_id").(string) + + store := helpers.Store(r) + + var app util.App + + if !helpers.Bind(w, r, &app) { + return + } + + options := structToFlatMap(app) + + for k, v := range options { + if err := setAppOption(store, appID, k, v); err != nil { + helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusNoContent) +} + +func setAppActive(w http.ResponseWriter, r *http.Request) { + appID := context.Get(r, "app_id").(string) + + store := helpers.Store(r) + + var body struct { + Active bool `json:"active"` + } + + if !helpers.Bind(w, r, &body) { + return + } + + if err := setAppOption(store, appID, "active", body.Active); err != nil { + helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/api/apps_test.go b/api/apps_test.go new file mode 100644 index 00000000..dbec0c89 --- /dev/null +++ b/api/apps_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "fmt" + "testing" +) + +func TestStructToMap(t *testing.T) { + type Address struct { + City string `json:"city"` + State string `json:"state"` + } + + type Person struct { + Name string `json:"name"` + Age int `json:"age"` + Email string `json:"email"` + Active bool `json:"active"` + Address Address `json:"address"` + } + + // Create an instance of the struct + p := Person{ + Name: "John Doe", + Age: 30, + Email: "johndoe@example.com", + Active: true, + Address: Address{ + City: "New York", + State: "NY", + }, + } + + // Convert the struct to a flat map + flatMap := structToFlatMap(&p) + + if flatMap["address.city"] != "New York" { + t.Fail() + } + // Print the map + fmt.Println(flatMap) +} diff --git a/api/auth.go b/api/auth.go index a41e9878..94571703 100644 --- a/api/auth.go +++ b/api/auth.go @@ -1,11 +1,11 @@ package api import ( - log "github.com/sirupsen/logrus" "github.com/ansible-semaphore/semaphore/api/helpers" "github.com/ansible-semaphore/semaphore/db" "github.com/ansible-semaphore/semaphore/util" "github.com/gorilla/context" + log "github.com/sirupsen/logrus" "net/http" "strings" "time" @@ -120,3 +120,16 @@ func authenticationWithStore(next http.Handler) http.Handler { } }) } + +func adminMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := context.Get(r, "user").(*db.User) + + if !user.Admin { + w.WriteHeader(http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/api/integration.go b/api/integration.go index 835be0fa..0d98bfe7 100644 --- a/api/integration.go +++ b/api/integration.go @@ -58,7 +58,7 @@ func ReceiveIntegration(w http.ResponseWriter, r *http.Request) { var integrations []db.Integration - if util.Config.GlobalIntegrationAlias != "" && integrationAlias == util.Config.GlobalIntegrationAlias { + if util.Config.IntegrationAlias != "" && integrationAlias == util.Config.IntegrationAlias { integrations, err = helpers.Store(r).GetAllSearchableIntegrations() } else { integrations, err = helpers.Store(r).GetIntegrationsByAlias(integrationAlias) diff --git a/api/login.go b/api/login.go index 067568a5..9c1ec6eb 100644 --- a/api/login.go +++ b/api/login.go @@ -65,7 +65,7 @@ func tryFindLDAPUser(username, password string) (*db.User, error) { return nil, err } - // Search for the given username + // Filter for the given username searchRequest := ldap.NewSearchRequest( util.Config.LdapSearchDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, diff --git a/api/options.go b/api/options.go new file mode 100644 index 00000000..a6bcae8b --- /dev/null +++ b/api/options.go @@ -0,0 +1,55 @@ +package api + +import ( + "github.com/ansible-semaphore/semaphore/api/helpers" + "github.com/ansible-semaphore/semaphore/db" + "github.com/gorilla/context" + "net/http" +) + +func setOption(w http.ResponseWriter, r *http.Request) { + currentUser := context.Get(r, "user").(*db.User) + + if !currentUser.Admin { + helpers.WriteJSON(w, http.StatusForbidden, map[string]string{ + "error": "User must be admin", + }) + return + } + + var option db.Option + if !helpers.Bind(w, r, &option) { + return + } + + err := helpers.Store(r).SetOption(option.Key, option.Value) + if err != nil { + helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Can not set option", + }) + return + } + + helpers.WriteJSON(w, http.StatusOK, option) +} + +func getOptions(w http.ResponseWriter, r *http.Request) { + currentUser := context.Get(r, "user").(*db.User) + + if !currentUser.Admin { + helpers.WriteJSON(w, http.StatusForbidden, map[string]string{ + "error": "User must be admin", + }) + return + } + + options, err := helpers.Store(r).GetOptions(db.RetrieveQueryParams{}) + if err != nil { + helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Can not get options", + }) + return + } + + helpers.WriteJSON(w, http.StatusOK, options) +} diff --git a/api/router.go b/api/router.go index 31f7fdbd..fd4bd7ea 100644 --- a/api/router.go +++ b/api/router.go @@ -102,7 +102,6 @@ func Route() *mux.Router { authenticatedWS.Path("/ws").HandlerFunc(sockets.Handler).Methods("GET", "HEAD") authenticatedAPI := r.PathPrefix(webPath + "api").Subrouter() - authenticatedAPI.Use(StoreMiddleware, JSONMiddleware, authentication) authenticatedAPI.Path("/info").HandlerFunc(getSystemInfo).Methods("GET", "HEAD") @@ -117,11 +116,25 @@ func Route() *mux.Router { authenticatedAPI.Path("/users").HandlerFunc(addUser).Methods("POST") authenticatedAPI.Path("/user").HandlerFunc(getUser).Methods("GET", "HEAD") + authenticatedAPI.Path("/apps").HandlerFunc(getApps).Methods("GET", "HEAD") + tokenAPI := authenticatedAPI.PathPrefix("/user").Subrouter() tokenAPI.Path("/tokens").HandlerFunc(getAPITokens).Methods("GET", "HEAD") tokenAPI.Path("/tokens").HandlerFunc(createAPIToken).Methods("POST") tokenAPI.HandleFunc("/tokens/{token_id}", expireAPIToken).Methods("DELETE") + adminAPI := authenticatedAPI.NewRoute().Subrouter() + adminAPI.Use(adminMiddleware) + adminAPI.Path("/options").HandlerFunc(getOptions).Methods("GET", "HEAD") + adminAPI.Path("/options").HandlerFunc(setOption).Methods("POST") + + appsAPI := adminAPI.PathPrefix("/apps").Subrouter() + appsAPI.Use(appMiddleware) + appsAPI.Path("/{app_id}").HandlerFunc(getApp).Methods("GET", "HEAD") + appsAPI.Path("/{app_id}").HandlerFunc(setApp).Methods("PUT", "POST") + appsAPI.Path("/{app_id}/active").HandlerFunc(setAppActive).Methods("POST") + appsAPI.Path("/{app_id}").HandlerFunc(deleteApp).Methods("DELETE") + userAPI := authenticatedAPI.Path("/users/{user_id}").Subrouter() userAPI.Use(getUserMiddleware) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index b7d734df..49091949 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -108,5 +108,13 @@ func createStore(token string) db.Store { panic(err) } + err = db.FillConfigFromDB(store) + + if err != nil { + panic(err) + } + + util.CheckDefaultApps() + return store } diff --git a/db/Migration.go b/db/Migration.go index 8d5f78c0..463dee59 100644 --- a/db/Migration.go +++ b/db/Migration.go @@ -70,6 +70,7 @@ func GetMigrations() []Migration { {Version: "2.9.100"}, {Version: "2.10.12"}, {Version: "2.10.15"}, + {Version: "2.10.16"}, } } diff --git a/db/Option.go b/db/Option.go index b9b806a9..dabd0c25 100644 --- a/db/Option.go +++ b/db/Option.go @@ -1,6 +1,24 @@ package db +import ( + "fmt" + "regexp" +) + type Option struct { Key string `db:"key" json:"key"` Value string `db:"value" json:"value"` } + +func ValidateOptionKey(key string) error { + m, err := regexp.Match(`^[\w.]+$`, []byte(key)) + if err != nil { + return err + } + + if !m { + return fmt.Errorf("invalid key format") + } + + return nil +} diff --git a/db/Store.go b/db/Store.go index e075d362..81d6a00c 100644 --- a/db/Store.go +++ b/db/Store.go @@ -38,6 +38,7 @@ type RetrieveQueryParams struct { Count int SortBy string SortInverted bool + Filter string } type ObjectReferrer struct { @@ -109,8 +110,11 @@ type Store interface { // if a rollback exists TryRollbackMigration(version Migration) + GetOptions(params RetrieveQueryParams) (map[string]string, error) GetOption(key string) (string, error) SetOption(key string, value string) error + DeleteOption(key string) error + DeleteOptions(filter string) error GetEnvironment(projectID int, environmentID int) (Environment, error) GetEnvironmentRefs(projectID int, environmentID int) (ObjectReferrers, error) diff --git a/db/Task.go b/db/Task.go index 8a1897d5..4d0ddea1 100644 --- a/db/Task.go +++ b/db/Task.go @@ -107,6 +107,7 @@ type TaskWithTpl struct { TemplatePlaybook string `db:"tpl_playbook" json:"tpl_playbook"` TemplateAlias string `db:"tpl_alias" json:"tpl_alias"` TemplateType TemplateType `db:"tpl_type" json:"tpl_type"` + TemplateApp string `db:"tpl_app" json:"tpl_app"` UserName *string `db:"user_name" json:"user_name"` BuildTask *Task `db:"-" json:"build_task"` } diff --git a/db/Template.go b/db/Template.go index b082f5df..c7305f90 100644 --- a/db/Template.go +++ b/db/Template.go @@ -15,11 +15,13 @@ const ( type TemplateApp string const ( - TemplateAnsible TemplateApp = "" - TemplateTerraform TemplateApp = "terraform" - TemplateTofu TemplateApp = "tofu" - TemplateBash TemplateApp = "bash" - TemplatePulumi TemplateApp = "pulumi" + TemplateAnsible TemplateApp = "ansible" + TemplateTerraform TemplateApp = "terraform" + TemplateTofu TemplateApp = "tofu" + TemplateBash TemplateApp = "bash" + TemplatePowerShell TemplateApp = "powershell" + TemplatePython TemplateApp = "python" + TemplatePulumi TemplateApp = "pulumi" ) func (t TemplateApp) IsTerraform() bool { diff --git a/db/bolt/migration.go b/db/bolt/migration.go index b07a6e3b..b3090f0b 100644 --- a/db/bolt/migration.go +++ b/db/bolt/migration.go @@ -43,6 +43,8 @@ func (d *BoltDb) ApplyMigration(m db.Migration) (err error) { err = migration_2_8_91{migration{d.db}}.Apply() case "2.10.12": err = migration_2_10_12{migration{d.db}}.Apply() + case "2.10.16": + err = migration_2_10_16{migration{d.db}}.Apply() } if err != nil { diff --git a/db/bolt/migration_2_10_16.go b/db/bolt/migration_2_10_16.go new file mode 100644 index 00000000..cfcb8d00 --- /dev/null +++ b/db/bolt/migration_2_10_16.go @@ -0,0 +1,38 @@ +package bolt + +type migration_2_10_16 struct { + migration +} + +func (d migration_2_10_16) Apply() (err error) { + projectIDs, err := d.getProjectIDs() + + if err != nil { + return + } + + templates := make(map[string]map[string]map[string]interface{}) + + for _, projectID := range projectIDs { + var err2 error + templates[projectID], err2 = d.getObjects(projectID, "template") + if err2 != nil { + return err2 + } + } + + for projectID, projectTemplates := range templates { + for repoID, tpl := range projectTemplates { + if tpl["app"] != nil && tpl["app"] != "" { + continue + } + tpl["app"] = "ansible" + err = d.setObject(projectID, "template", repoID, tpl) + if err != nil { + return err + } + } + } + + return +} diff --git a/db/bolt/migration_2_10_16_test.go b/db/bolt/migration_2_10_16_test.go new file mode 100644 index 00000000..c5efee23 --- /dev/null +++ b/db/bolt/migration_2_10_16_test.go @@ -0,0 +1,88 @@ +package bolt + +import ( + "encoding/json" + "go.etcd.io/bbolt" + "testing" +) + +func TestMigration_2_10_16_Apply(t *testing.T) { + store := CreateTestStore() + + err := store.db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("project")) + if err != nil { + return err + } + + err = b.Put([]byte("0000000001"), []byte("{}")) + if err != nil { + return err + } + + r, err := tx.CreateBucketIfNotExists([]byte("project__template_0000000001")) + if err != nil { + return err + } + + err = r.Put([]byte("0000000001"), + []byte("{\"id\":\"1\",\"project_id\":\"1\"}")) + + return err + }) + + if err != nil { + t.Fatal(err) + } + + err = migration_2_10_16{migration{store.db}}.Apply() + if err != nil { + t.Fatal(err) + } + + var repo map[string]interface{} + err = store.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket([]byte("project__template_0000000001")) + str := string(b.Get([]byte("0000000001"))) + return json.Unmarshal([]byte(str), &repo) + }) + if err != nil { + t.Fatal(err) + } + + if repo["app"] == nil { + t.Fatal("app must be set") + } + + if repo["app"].(string) != "ansible" { + t.Fatal("invalid app: " + repo["app"].(string)) + } + + if repo["alias"] != nil { + t.Fatal("alias must be deleted") + } +} + +func TestMigration_2_10_16_Apply2(t *testing.T) { + store := CreateTestStore() + + err := store.db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("project")) + if err != nil { + return err + } + + err = b.Put([]byte("0000000001"), []byte("{}")) + + return err + }) + + if err != nil { + t.Fatal(err) + } + + err = migration_2_10_16{migration{store.db}}.Apply() + if err != nil { + t.Fatal(err) + } +} diff --git a/db/bolt/option.go b/db/bolt/option.go index dbec854d..86ff6c27 100644 --- a/db/bolt/option.go +++ b/db/bolt/option.go @@ -3,8 +3,29 @@ package bolt import ( "errors" "github.com/ansible-semaphore/semaphore/db" + "go.etcd.io/bbolt" + "strings" ) +func (d *BoltDb) GetOptions(params db.RetrieveQueryParams) (res map[string]string, err error) { + res = make(map[string]string) + var options []db.Option + err = d.getObjects(0, db.OptionProps, db.RetrieveQueryParams{}, func(i interface{}) bool { + + option := i.(db.Option) + if params.Filter == "" { + return true + } + + return option.Key == params.Filter || strings.HasPrefix(option.Key, params.Filter+".") + + }, &options) + for _, opt := range options { + res[opt.Key] = opt.Value + } + return +} + func (d *BoltDb) SetOption(key string, value string) error { opt := db.Option{ @@ -42,3 +63,37 @@ func (d *BoltDb) GetOption(key string) (value string, err error) { return } + +func (d *BoltDb) DeleteOption(key string) (err error) { + err = db.ValidateOptionKey(key) + if err != nil { + return + } + + return d.db.Update(func(tx *bbolt.Tx) error { + return d.deleteObject(-1, db.OptionProps, strObjectID(key), tx) + }) +} + +func (d *BoltDb) DeleteOptions(filter string) (err error) { + err = db.ValidateOptionKey(filter) + if err != nil { + return + } + + var options []db.Option + + err = d.getObjects(0, db.OptionProps, db.RetrieveQueryParams{}, func(i interface{}) bool { + opt := i.(db.Option) + return opt.Key == filter || strings.HasPrefix(opt.Key, filter+".") + }, &options) + + for _, opt := range options { + err = d.DeleteOption(opt.Key) + if err != nil { + return + } + } + + return +} diff --git a/db/config.go b/db/config.go new file mode 100644 index 00000000..510e7946 --- /dev/null +++ b/db/config.go @@ -0,0 +1,169 @@ +package db + +import ( + "fmt" + "github.com/ansible-semaphore/semaphore/util" + "reflect" + "strings" +) + +func ConvertFlatToNested(flatMap map[string]string) map[string]interface{} { + nestedMap := make(map[string]interface{}) + + for key, value := range flatMap { + parts := strings.Split(key, ".") + currentMap := nestedMap + + for i, part := range parts { + if i == len(parts)-1 { + currentMap[part] = value + } else { + if _, exists := currentMap[part]; !exists { + currentMap[part] = make(map[string]interface{}) + } + currentMap = currentMap[part].(map[string]interface{}) + } + } + } + + return nestedMap +} + +func AssignMapToStruct[P *S, S any](m map[string]interface{}, s P) error { + v := reflect.ValueOf(s).Elem() + return assignMapToStructRecursive(m, v) +} + +func cloneStruct(origValue reflect.Value) reflect.Value { + // Create a new instance of the same type as the original struct + cloneValue := reflect.New(origValue.Type()).Elem() + + // Iterate over the fields of the struct + for i := 0; i < origValue.NumField(); i++ { + // Get the field value + fieldValue := origValue.Field(i) + // Set the field value in the clone + cloneValue.Field(i).Set(fieldValue) + } + + // Return the cloned struct + return cloneValue +} + +func assignMapToStructRecursive(m map[string]interface{}, structValue reflect.Value) error { + structType := structValue.Type() + + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + jsonTag := field.Tag.Get("json") + if jsonTag == "" { + jsonTag = field.Name + } else { + jsonTag = strings.Split(jsonTag, ",")[0] + } + + if value, ok := m[jsonTag]; ok { + fieldValue := structValue.FieldByName(field.Name) + if fieldValue.CanSet() { + + val := reflect.ValueOf(value) + + switch fieldValue.Kind() { + case reflect.Struct: + + if val.Kind() != reflect.Map { + return fmt.Errorf("expected map for nested struct field %s but got %T", field.Name, value) + } + + mapValue, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("cannot assign value of type %T to field %s of type %s", value, field.Name, field.Type) + } + err := assignMapToStructRecursive(mapValue, fieldValue) + if err != nil { + return err + } + case reflect.Map: + if fieldValue.IsNil() { + mapValue := reflect.MakeMap(fieldValue.Type()) + fieldValue.Set(mapValue) + } + + // Handle map + if val.Kind() != reflect.Map { + return fmt.Errorf("expected map for field %s but got %T", field.Name, value) + } + + for _, key := range val.MapKeys() { + mapElemValue := val.MapIndex(key) + mapElemType := fieldValue.Type().Elem() + + srcVal := fieldValue.MapIndex(key) + var mapElem reflect.Value + if srcVal.IsValid() { + mapElem = cloneStruct(srcVal) + } else { + mapElem = reflect.New(mapElemType).Elem() + } + + if mapElemType.Kind() == reflect.Struct { + if err := assignMapToStructRecursive(mapElemValue.Interface().(map[string]interface{}), mapElem); err != nil { + return err + } + } else { + if mapElemValue.Type().ConvertibleTo(mapElemType) { + mapElem.Set(mapElemValue.Convert(mapElemType)) + } else { + newVal, converted := util.CastValueToKind(mapElemValue.Interface(), mapElemType.Kind()) + if !converted { + return fmt.Errorf("cannot assign value of type %s to map element of type %s", + mapElemValue.Type(), mapElemType) + } + + mapElem.Set(reflect.ValueOf(newVal)) + } + + } + + fieldValue.SetMapIndex(key, mapElem) + } + + default: + // Handle simple types + if val.Type().ConvertibleTo(fieldValue.Type()) { + fieldValue.Set(val.Convert(fieldValue.Type())) + } else { + + newVal, converted := util.CastValueToKind(val.Interface(), fieldValue.Type().Kind()) + if !converted { + return fmt.Errorf("cannot assign value of type %s to map element of type %s", + val.Type(), val) + } + + fieldValue.Set(reflect.ValueOf(newVal)) + } + } + } + } + } + return nil +} + +func FillConfigFromDB(store Store) (err error) { + + opts, err := store.GetOptions(RetrieveQueryParams{}) + + if err != nil { + return + } + + options := ConvertFlatToNested(opts) + + if options["apps"] == nil { + options["apps"] = make(map[string]interface{}) + } + + err = AssignMapToStruct(options, util.Config) + + return +} diff --git a/db/config_test.go b/db/config_test.go new file mode 100644 index 00000000..74bb8bba --- /dev/null +++ b/db/config_test.go @@ -0,0 +1,75 @@ +package db + +import "testing" + +func TestConfig_assignMapToStruct(t *testing.T) { + type Address struct { + Street string `json:"street"` + City string `json:"city"` + } + + type Detail struct { + Value string `json:"value"` + Description string `json:"description"` + } + + type User struct { + //Name string `json:"name"` + //Age int `json:"age"` + //Email string `json:"email"` + //Address Address `json:"address"` + Details map[string]Detail `json:"details"` + } + + johnData := map[string]interface{}{ + //"name": "John Doe", + //"age": 30, + //"email": "john.doe@example.com", + //"address": map[string]interface{}{ + // "street": "123 Main St", + // "city": "Anytown", + //}, + "details": map[string]interface{}{ + //"occupation": map[string]interface{}{ + // "value": "engineer", + // "description": "Works with computers", + //}, + //"hobby": map[string]interface{}{ + // "value": "hiking", + // "description": "Enjoys the outdoors", + //}, + "interests": map[string]interface{}{ + "description": "Ho ho ho", + }, + }, + } + + var john User + john.Details = make(map[string]Detail) + john.Details["interests"] = Detail{ + Value: "politics", + Description: "Follows current events", + } + + err := AssignMapToStruct(johnData, &john) + + if err != nil { + t.Fatal(err) + } + + //if john.Name != "John Doe" { + // t.Errorf("Expected name to be John Doe but got %s", john.Name) + //} + + if john.Details["interests"].Description != "Ho ho ho" { + t.Errorf("Expected interests description to be 'Ho ho ho' but got %s", john.Details["interests"].Description) + } + + if john.Details["interests"].Value != "politics" { + t.Errorf("Expected interests to be politics but got '%s'", john.Details["interests"].Value) + } + + //if john.Details["occupation"].Value != "engineer" { + // t.Errorf("Expected occupation to be engineer but got %s", john.Details["occupation"].Value) + //} +} diff --git a/db/sql/SqlDb.go b/db/sql/SqlDb.go index faa2eeb5..5fbd4104 100644 --- a/db/sql/SqlDb.go +++ b/db/sql/SqlDb.go @@ -204,7 +204,7 @@ func (d *SqlDb) getObject(projectID int, props db.ObjectProps, objectID int, obj func (d *SqlDb) makeObjectsQuery(projectID int, props db.ObjectProps, params db.RetrieveQueryParams) squirrel.SelectBuilder { q := squirrel.Select("*"). - From(props.TableName + " pe") + From("`" + props.TableName + "` pe") if !props.IsGlobal { q = q.Where("pe.project_id=?", projectID) @@ -235,8 +235,14 @@ func (d *SqlDb) makeObjectsQuery(projectID int, props db.ObjectProps, params db. return q } -func (d *SqlDb) getObjects(projectID int, props db.ObjectProps, params db.RetrieveQueryParams, objects interface{}) (err error) { - query, args, err := d.makeObjectsQuery(projectID, props, params).ToSql() +func (d *SqlDb) getObjects(projectID int, props db.ObjectProps, params db.RetrieveQueryParams, prepare func(squirrel.SelectBuilder) squirrel.SelectBuilder, objects interface{}) (err error) { + q := d.makeObjectsQuery(projectID, props, params) + + if prepare != nil { + q = prepare(q) + } + + query, args, err := q.ToSql() if err != nil { return @@ -247,7 +253,7 @@ func (d *SqlDb) getObjects(projectID int, props db.ObjectProps, params db.Retrie return } -func (d *SqlDb) deleteObject(projectID int, props db.ObjectProps, objectID int) error { +func (d *SqlDb) deleteObject(projectID int, props db.ObjectProps, objectID any) error { if props.IsGlobal { return validateMutationResult( d.exec( diff --git a/db/sql/access_key.go b/db/sql/access_key.go index f6b9c8bd..6a809659 100644 --- a/db/sql/access_key.go +++ b/db/sql/access_key.go @@ -105,7 +105,7 @@ func (d *SqlDb) RekeyAccessKeys(oldKey string) (err error) { for i := 0; ; i++ { var keys []db.AccessKey - err = d.getObjects(-1, globalProps, db.RetrieveQueryParams{Count: RekeyBatchSize, Offset: i * RekeyBatchSize}, &keys) + err = d.getObjects(-1, globalProps, db.RetrieveQueryParams{Count: RekeyBatchSize, Offset: i * RekeyBatchSize}, nil, &keys) if err != nil { return diff --git a/db/sql/environment.go b/db/sql/environment.go index 4a47a126..daad769d 100644 --- a/db/sql/environment.go +++ b/db/sql/environment.go @@ -15,7 +15,7 @@ func (d *SqlDb) GetEnvironmentRefs(projectID int, environmentID int) (db.ObjectR func (d *SqlDb) GetEnvironments(projectID int, params db.RetrieveQueryParams) ([]db.Environment, error) { var environment []db.Environment - err := d.getObjects(projectID, db.EnvironmentProps, params, &environment) + err := d.getObjects(projectID, db.EnvironmentProps, params, nil, &environment) return environment, err } diff --git a/db/sql/integration.go b/db/sql/integration.go index 865ebe6c..281a198a 100644 --- a/db/sql/integration.go +++ b/db/sql/integration.go @@ -37,7 +37,7 @@ func (d *SqlDb) CreateIntegration(integration db.Integration) (newIntegration db } func (d *SqlDb) GetIntegrations(projectID int, params db.RetrieveQueryParams) (integrations []db.Integration, err error) { - err = d.getObjects(projectID, db.IntegrationProps, params, &integrations) + err = d.getObjects(projectID, db.IntegrationProps, params, nil, &integrations) return integrations, err } diff --git a/db/sql/inventory.go b/db/sql/inventory.go index 4e30170a..43e49add 100644 --- a/db/sql/inventory.go +++ b/db/sql/inventory.go @@ -14,7 +14,7 @@ func (d *SqlDb) GetInventory(projectID int, inventoryID int) (inventory db.Inven func (d *SqlDb) GetInventories(projectID int, params db.RetrieveQueryParams) ([]db.Inventory, error) { var inventories []db.Inventory - err := d.getObjects(projectID, db.InventoryProps, params, &inventories) + err := d.getObjects(projectID, db.InventoryProps, params, nil, &inventories) return inventories, err } diff --git a/db/sql/migrations/v2.10.16.sql b/db/sql/migrations/v2.10.16.sql new file mode 100644 index 00000000..e0a46e88 --- /dev/null +++ b/db/sql/migrations/v2.10.16.sql @@ -0,0 +1,3 @@ +update `project__template` set `app` = 'ansible' where `app` = ''; + +alter table `project__template` change `app` `app` varchar(50) not null; diff --git a/db/sql/option.go b/db/sql/option.go index 0589f271..a38d0192 100644 --- a/db/sql/option.go +++ b/db/sql/option.go @@ -22,6 +22,35 @@ func (d *SqlDb) SetOption(key string, value string) error { return err } +func (d *SqlDb) GetOptions(params db.RetrieveQueryParams) (res map[string]string, err error) { + var options []db.Option + res = make(map[string]string) + + if params.Filter != "" { + err = db.ValidateOptionKey(params.Filter) + if err != nil { + return + } + } + + err = d.getObjects(0, db.OptionProps, params, func(q squirrel.SelectBuilder) squirrel.SelectBuilder { + if params.Filter == "" { + return q + } + return q.Where("`key` = ? OR `key` LIKE ?", params.Filter, params.Filter+".%") + }, &options) + + if err != nil { + return + } + + for _, opt := range options { + res[opt.Key] = opt.Value + } + + return +} + func (d *SqlDb) getOption(key string) (value string, err error) { q := squirrel.Select("*"). From("`"+db.OptionProps.TableName+"`"). @@ -56,3 +85,25 @@ func (d *SqlDb) GetOption(key string) (value string, err error) { return } + +func (d *SqlDb) DeleteOption(key string) (err error) { + err = db.ValidateOptionKey(key) + if err != nil { + return + } + + err = d.deleteObject(0, db.OptionProps, key) + + return +} + +func (d *SqlDb) DeleteOptions(filter string) (err error) { + err = db.ValidateOptionKey(filter) + if err != nil { + return + } + + _, err = d.exec("DELETE FROM `option` WHERE `key` = ? OR `key` LIKE ?", filter, filter+".%") + + return +} diff --git a/db/sql/runner.go b/db/sql/runner.go index 535ce27a..b9e793c4 100644 --- a/db/sql/runner.go +++ b/db/sql/runner.go @@ -24,7 +24,7 @@ func (d *SqlDb) GetGlobalRunner(runnerID int) (runner db.Runner, err error) { } func (d *SqlDb) GetGlobalRunners() (runners []db.Runner, err error) { - err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, &runners) + err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, nil, &runners) return } diff --git a/db/sql/task.go b/db/sql/task.go index 992c592a..4354b101 100644 --- a/db/sql/task.go +++ b/db/sql/task.go @@ -116,7 +116,8 @@ func (d *SqlDb) getTasks(projectID int, templateIDs []int, params db.RetrieveQue fields += ", tpl.playbook as tpl_playbook" + ", `user`.name as user_name" + ", tpl.name as tpl_alias" + - ", tpl.type as tpl_type" + ", tpl.type as tpl_type" + + ", tpl.app as tpl_app" q := squirrel.Select(fields). From("task"). diff --git a/db/sql/view.go b/db/sql/view.go index 691805a7..473ef636 100644 --- a/db/sql/view.go +++ b/db/sql/view.go @@ -8,7 +8,7 @@ func (d *SqlDb) GetView(projectID int, viewID int) (view db.View, err error) { } func (d *SqlDb) GetViews(projectID int) (views []db.View, err error) { - err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, &views) + err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, nil, &views) return } diff --git a/db_lib/AppFactory.go b/db_lib/AppFactory.go index 3e10ee05..55783b07 100644 --- a/db_lib/AppFactory.go +++ b/db_lib/AppFactory.go @@ -32,13 +32,14 @@ func CreateApp(template db.Template, repository db.Repository, logger task_logge Logger: logger, Name: TerraformAppTofu, } - case db.TemplateBash: - return &BashApp{ + case db.TemplateBash, db.TemplatePowerShell, db.TemplatePython: + return &ShellApp{ Template: template, Repository: repository, Logger: logger, + App: template.App, } default: - panic("unknown app") + panic("unknown app: " + template.App) } } diff --git a/db_lib/BashApp.go b/db_lib/ShellApp.go similarity index 63% rename from db_lib/BashApp.go rename to db_lib/ShellApp.go index 6b0d5bbb..1cdb5a77 100644 --- a/db_lib/BashApp.go +++ b/db_lib/ShellApp.go @@ -11,10 +11,11 @@ import ( "time" ) -type BashApp struct { +type ShellApp struct { Logger task_logger.Logger Template db.Template Repository db.Repository + App db.TemplateApp reader bashReader } @@ -39,7 +40,7 @@ func (r *bashReader) Read(p []byte) (n int, err error) { return len(*r.input) + 1, nil } -func (t *BashApp) makeCmd(command string, args []string, environmentVars *[]string) *exec.Cmd { +func (t *ShellApp) makeCmd(command string, args []string, environmentVars *[]string) *exec.Cmd { cmd := exec.Command(command, args...) //nolint: gas cmd.Dir = t.GetFullPath() @@ -47,10 +48,6 @@ func (t *BashApp) makeCmd(command string, args []string, environmentVars *[]stri cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", util.Config.TmpPath)) cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir)) - if environmentVars != nil { - cmd.Env = append(cmd.Env, args...) - } - if environmentVars != nil { cmd.Env = append(cmd.Env, *environmentVars...) } @@ -63,18 +60,18 @@ func (t *BashApp) makeCmd(command string, args []string, environmentVars *[]stri return cmd } -func (t *BashApp) runCmd(command string, args []string) error { +func (t *ShellApp) runCmd(command string, args []string) error { cmd := t.makeCmd(command, args, nil) t.Logger.LogCmd(cmd) return cmd.Run() } -func (t *BashApp) GetFullPath() (path string) { +func (t *ShellApp) GetFullPath() (path string) { path = t.Repository.GetFullPath(t.Template.ID) return } -func (t *BashApp) SetLogger(logger task_logger.Logger) task_logger.Logger { +func (t *ShellApp) SetLogger(logger task_logger.Logger) task_logger.Logger { t.Logger = logger t.Logger.AddStatusListener(func(status task_logger.TaskStatus) { @@ -83,12 +80,27 @@ func (t *BashApp) SetLogger(logger task_logger.Logger) task_logger.Logger { return logger } -func (t *BashApp) InstallRequirements() error { +func (t *ShellApp) InstallRequirements() error { return nil } -func (t *BashApp) Run(args []string, environmentVars *[]string, inputs map[string]string, cb func(*os.Process)) error { - cmd := t.makeCmd("bash", args, environmentVars) +func (t *ShellApp) makeShellCmd(args []string, environmentVars *[]string) *exec.Cmd { + var command string + var appArgs []string + switch t.App { + case db.TemplateBash: + command = "bash" + case db.TemplatePython: + command = "python" + case db.TemplatePowerShell: + command = "powershell" + appArgs = []string{"-File"} + } + return t.makeCmd(command, append(appArgs, args...), environmentVars) +} + +func (t *ShellApp) Run(args []string, environmentVars *[]string, inputs map[string]string, cb func(*os.Process)) error { + cmd := t.makeShellCmd(args, environmentVars) t.Logger.LogCmd(cmd) //cmd.Stdin = &t.reader cmd.Stdin = strings.NewReader("") diff --git a/services/tasks/LocalJob.go b/services/tasks/LocalJob.go index 87f12031..12197b8a 100644 --- a/services/tasks/LocalJob.go +++ b/services/tasks/LocalJob.go @@ -378,7 +378,7 @@ func (t *LocalJob) Run(username string, incomingVersion *string) (err error) { args, inputs, err = t.getPlaybookArgs(username, incomingVersion) case db.TemplateTerraform, db.TemplateTofu: args, err = t.getTerraformArgs(username, incomingVersion) - case db.TemplateBash: + case db.TemplateBash, db.TemplatePowerShell, db.TemplatePython: args, err = t.getBashArgs(username, incomingVersion) default: panic("unknown template app") diff --git a/util/App.go b/util/App.go new file mode 100644 index 00000000..b7e20478 --- /dev/null +++ b/util/App.go @@ -0,0 +1,12 @@ +package util + +type App struct { + Active bool `json:"active"` + Order int `json:"order"` + Title string `json:"title"` + Icon string `json:"icon"` + Color string `json:"color"` + DarkColor string `json:"dark_color"` + AppPath string `json:"path"` + AppArgs []string `json:"args"` +} diff --git a/util/OdbcProvider.go b/util/OdbcProvider.go new file mode 100644 index 00000000..1f0ac797 --- /dev/null +++ b/util/OdbcProvider.go @@ -0,0 +1,37 @@ +package util + +type OidcProvider struct { + ClientID string `json:"client_id"` + ClientIDFile string `json:"client_id_file"` + ClientSecret string `json:"client_secret"` + ClientSecretFile string `json:"client_secret_file"` + RedirectURL string `json:"redirect_url"` + Scopes []string `json:"scopes"` + DisplayName string `json:"display_name"` + Color string `json:"color"` + Icon string `json:"icon"` + AutoDiscovery string `json:"provider_url"` + Endpoint oidcEndpoint `json:"endpoint"` + UsernameClaim string `json:"username_claim" default:"preferred_username"` + NameClaim string `json:"name_claim" default:"preferred_username"` + EmailClaim string `json:"email_claim" default:"email"` + Order int `json:"order"` +} + +type ClaimsProvider interface { + GetUsernameClaim() string + GetEmailClaim() string + GetNameClaim() string +} + +func (p *OidcProvider) GetUsernameClaim() string { + return p.UsernameClaim +} + +func (p *OidcProvider) GetEmailClaim() string { + return p.EmailClaim +} + +func (p *OidcProvider) GetNameClaim() string { + return p.NameClaim +} diff --git a/util/config.go b/util/config.go index 102dd14b..c4191121 100644 --- a/util/config.go +++ b/util/config.go @@ -71,42 +71,6 @@ type oidcEndpoint struct { Algorithms []string `json:"algorithms"` } -type OidcProvider struct { - ClientID string `json:"client_id"` - ClientIDFile string `json:"client_id_file"` - ClientSecret string `json:"client_secret"` - ClientSecretFile string `json:"client_secret_file"` - RedirectURL string `json:"redirect_url"` - Scopes []string `json:"scopes"` - DisplayName string `json:"display_name"` - Color string `json:"color"` - Icon string `json:"icon"` - AutoDiscovery string `json:"provider_url"` - Endpoint oidcEndpoint `json:"endpoint"` - UsernameClaim string `json:"username_claim" default:"preferred_username"` - NameClaim string `json:"name_claim" default:"preferred_username"` - EmailClaim string `json:"email_claim" default:"email"` - Order int `json:"order"` -} - -type ClaimsProvider interface { - GetUsernameClaim() string - GetEmailClaim() string - GetNameClaim() string -} - -func (p *OidcProvider) GetUsernameClaim() string { - return p.UsernameClaim -} - -func (p *OidcProvider) GetEmailClaim() string { - return p.EmailClaim -} - -func (p *OidcProvider) GetNameClaim() string { - return p.NameClaim -} - const ( // GoGitClientId is builtin Git client. It is not require external dependencies and is preferred. // Use it if you don't need external SSH authorization. @@ -143,17 +107,6 @@ type RunnerSettings struct { MaxParallelTasks int `json:"max_parallel_tasks" default:"1" env:"SEMAPHORE_RUNNER_MAX_PARALLEL_TASKS"` } -type AppVersion struct { - Semver string `json:"semver"` - DownloadURL string `json:"download_url"` - Path string `json:"path"` -} - -type AppConfig struct { - Name string `json:"name"` - Versions []AppVersion `json:"versions"` -} - // ConfigType mapping between Config and the json file that sets it type ConfigType struct { MySQL DbConfig `json:"mysql"` @@ -238,9 +191,9 @@ type ConfigType struct { Runner RunnerSettings `json:"runner"` - GlobalIntegrationAlias string `json:"global_integration_alias" env:"g"` + IntegrationAlias string `json:"global_integration_alias" env:"SEMAPHORE_INTEGRATION_ALIAS"` - Apps []AppConfig `json:"apps" env:"SEMAPHORE_APPS"` + Apps map[string]App `json:"apps" env:"SEMAPHORE_APPS"` } // Config exposes the application configuration storage for use in the application @@ -441,28 +394,52 @@ func castStringToBool(value string) bool { } +func CastValueToKind(value interface{}, kind reflect.Kind) (res interface{}, ok bool) { + res = value + + switch kind { + case reflect.Slice: + if reflect.ValueOf(value).Kind() == reflect.String { + var arr []string + err := json.Unmarshal([]byte(value.(string)), &arr) + if err != nil { + panic(err) + } + res = arr + ok = true + } + case reflect.String: + ok = true + case reflect.Int: + if reflect.ValueOf(value).Kind() != reflect.Int { + res = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value))) + ok = true + } + case reflect.Bool: + if reflect.ValueOf(value).Kind() != reflect.Bool { + res = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value))) + ok = true + } + case reflect.Map: + if reflect.ValueOf(value).Kind() == reflect.String { + mapValue := make(map[string]string) + err := json.Unmarshal([]byte(value.(string)), &mapValue) + if err != nil { + panic(err) + } + res = mapValue + ok = true + } + default: + } + + return +} + func setConfigValue(attribute reflect.Value, value interface{}) { if attribute.IsValid() { - switch attribute.Kind() { - case reflect.Int: - if reflect.ValueOf(value).Kind() != reflect.Int { - value = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value))) - } - case reflect.Bool: - if reflect.ValueOf(value).Kind() != reflect.Bool { - value = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value))) - } - case reflect.Map: - if reflect.ValueOf(value).Kind() == reflect.String { - mapValue := make(map[string]string) - err := json.Unmarshal([]byte(value.(string)), &mapValue) - if err != nil { - panic(err) - } - value = mapValue - } - } + value, _ = CastValueToKind(value, attribute.Kind()) attribute.Set(reflect.ValueOf(value)) } else { panic(fmt.Errorf("got non-existent config attribute")) @@ -822,3 +799,32 @@ func (conf *ConfigType) GenerateSecrets() { conf.CookieEncryption = base64.StdEncoding.EncodeToString(encryption) conf.AccessKeyEncryption = base64.StdEncoding.EncodeToString(accessKeyEncryption) } + +func CheckDefaultApps() { + appCommands := map[string]string{ + "": "ansible-playbook", + "terraform": "terraform", + "tofu": "tofu", + "bash": "bash", + } + + for app, cmd := range appCommands { + if _, ok := Config.Apps[app]; ok { + continue + } + + _, err := exec.LookPath(cmd) + + if err != nil { + continue + } + + if Config.Apps == nil { + Config.Apps = make(map[string]App) + } + + Config.Apps[app] = App{ + Active: true, + } + } +} diff --git a/web/src/components/AppForm.vue b/web/src/components/AppForm.vue new file mode 100644 index 00000000..251d3f22 --- /dev/null +++ b/web/src/components/AppForm.vue @@ -0,0 +1,97 @@ + + diff --git a/web/src/components/AppsMixin.js b/web/src/components/AppsMixin.js new file mode 100644 index 00000000..7b8a9cb3 --- /dev/null +++ b/web/src/components/AppsMixin.js @@ -0,0 +1,75 @@ +import axios from 'axios'; +import { APP_ICONS, APP_TITLE } from '../lib/constants'; + +export default { + data() { + return { + activeAppIds: [], + apps: null, + }; + }, + + async created() { + const apps = await this.loadAppsDataFromBackend(); + + this.activeAppIds = apps.filter((app) => app.active).map((app) => app.id); + + this.apps = apps.reduce((prev, app) => ({ + ...prev, + [app.id]: app, + }), {}); + }, + + computed: { + isAppsLoaded() { + return this.apps != null; + }, + }, + + methods: { + async loadAppsDataFromBackend() { + return (await axios({ + method: 'get', + url: '/api/apps', + responseType: 'json', + })).data; + }, + + getAppColor(id) { + if (APP_ICONS[id]) { + return this.$vuetify.theme.dark ? APP_ICONS[id].darkColor : APP_ICONS[id].color; + } + + if (this.apps[id]) { + return this.apps[id].color || 'gray'; + } + + return 'gray'; + }, + + getAppTitle(id) { + if (APP_TITLE[id]) { + return APP_TITLE[id]; + } + + if (this.apps[id]) { + return this.apps[id].title; + } + + return ''; + }, + + getAppIcon(id) { + if (APP_ICONS[id]) { + return APP_ICONS[id].icon; + } + + if (this.apps[id]) { + return `mdi-${this.apps[id].icon}`; + } + + return 'mdi-help'; + }, + + }, +}; diff --git a/web/src/components/BashTemplateForm.vue b/web/src/components/BashTemplateForm.vue deleted file mode 100644 index 48124019..00000000 --- a/web/src/components/BashTemplateForm.vue +++ /dev/null @@ -1,504 +0,0 @@ - - - diff --git a/web/src/components/EditTemplateDialogue.vue b/web/src/components/EditTemplateDialog.vue similarity index 54% rename from web/src/components/EditTemplateDialogue.vue rename to web/src/components/EditTemplateDialog.vue index 0be7ed6a..82864e28 100644 --- a/web/src/components/EditTemplateDialogue.vue +++ b/web/src/components/EditTemplateDialog.vue @@ -4,15 +4,14 @@ :min-content-height="457" v-model="dialog" :save-button-text="itemId === 'new' ? $t('create') : $t('save')" - :icon="APP_ICONS[itemApp].icon" - :icon-color="$vuetify.theme.dark ? APP_ICONS[itemApp].darkColor : APP_ICONS[itemApp].color" + :icon="getAppIcon(itemApp)" + :icon-color="getAppColor(itemApp)" :title="(itemId === 'new' ? $t('newTemplate') : $t('editTemplate')) + - ' \'' + APP_TITLE[itemApp] + '\''" + ' \'' + getAppTitle(itemApp) + '\''" @save="onSave" > @@ -52,20 +32,63 @@ diff --git a/web/src/lib/constants.js b/web/src/lib/constants.js index 56043d91..fc6746cc 100644 --- a/web/src/lib/constants.js +++ b/web/src/lib/constants.js @@ -74,7 +74,7 @@ export const EXTRACT_VALUE_BODY_DATA_TYPE_ICONS = { }; export const APP_ICONS = { - '': { + ansible: { icon: 'mdi-ansible', color: 'black', darkColor: 'white', @@ -102,7 +102,7 @@ export const APP_ICONS = { }; export const APP_TITLE = { - '': 'Ansible Playbook', + ansible: 'Ansible Playbook', terraform: 'Terraform Code', tofu: 'OpenTofu Code', bash: 'Bash Script', @@ -110,7 +110,9 @@ export const APP_TITLE = { }; export const APP_INVENTORY_TITLE = { - '': 'Ansible Inventory', + ansible: 'Ansible Inventory', terraform: 'Terraform Workspace', tofu: 'OpenTofu Workspace', }; + +export const DEFAULT_APPS = Object.keys(APP_ICONS); diff --git a/web/src/router/index.js b/web/src/router/index.js index e3d165fd..3be46e8e 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -16,6 +16,7 @@ import Auth from '../views/Auth.vue'; import New from '../views/project/New.vue'; import Integrations from '../views/project/Integrations.vue'; import IntegrationExtractor from '../views/project/IntegrationExtractor.vue'; +import Apps from '../views/Apps.vue'; Vue.use(VueRouter); @@ -100,6 +101,10 @@ const routes = [ path: '/users', component: Users, }, + { + path: '/apps', + component: Apps, + }, ]; const router = new VueRouter({ diff --git a/web/src/views/Apps.vue b/web/src/views/Apps.vue new file mode 100644 index 00000000..dbebc153 --- /dev/null +++ b/web/src/views/Apps.vue @@ -0,0 +1,178 @@ + + diff --git a/web/src/views/Options.vue b/web/src/views/Options.vue new file mode 100644 index 00000000..e69de29b diff --git a/web/src/views/project/History.vue b/web/src/views/project/History.vue index 880ba1f1..0621952c 100644 --- a/web/src/views/project/History.vue +++ b/web/src/views/project/History.vue @@ -30,6 +30,13 @@ >