diff --git a/src/cmd/project.go b/src/cmd/project.go index c494b836..ed378586 100644 --- a/src/cmd/project.go +++ b/src/cmd/project.go @@ -13,5 +13,6 @@ func projectCmd() *cmdBuilder.Cmd { AddChildrenCmd(projectListCmd()). AddChildrenCmd(projectDeleteCmd()). AddChildrenCmd(projectServiceImportCmd()). - AddChildrenCmd(projectImportCmd()) + AddChildrenCmd(projectImportCmd()). + AddChildrenCmd(projectServiceAddCmd()) } diff --git a/src/cmd/projectServiceAdd.go b/src/cmd/projectServiceAdd.go new file mode 100644 index 00000000..a20a3f92 --- /dev/null +++ b/src/cmd/projectServiceAdd.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "context" + + "github.com/zeropsio/zcli/src/cmd/scope" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/entity/repository" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxHelpers" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/dto/input/path" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/enum" + "github.com/zeropsio/zerops-go/types/stringId" +) + +const serviceAddArgName = "serviceAddName" +const serviceAddArgType = "type" +const serviceAddArgHa = "ha" + +func projectServiceAddCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("service-add"). + Short(i18n.T(i18n.CmdDescProjectServiceAdd)). + ScopeLevel(scope.Project). + Arg(serviceAddArgName). + StringFlag(serviceAddArgType, "", i18n.T(i18n.ServiceAddTypeFlag)). + BoolFlag(serviceAddArgHa, false, i18n.T(i18n.ServiceAddHaFlag)). + HelpFlag(i18n.T(i18n.CmdHelpProjectServiceAdd)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + name := cmdData.Args[serviceAddArgName][0] + + var typeNameVersion entity.ServiceStackTypeVersion + var typeNameVersionId stringId.ServiceStackTypeVersionId + + if cmdData.Params.GetString(serviceAddArgType) == "" { + serviceStackType, err := uxHelpers.PrintServiceStackTypeSelector(ctx, cmdData.UxBlocks, cmdData.RestApiClient) + if err != nil { + return err + } + if len(serviceStackType.Versions) == 1 { + typeNameVersion = serviceStackType.Versions[0] + } else { + typeNameVersion, err = uxHelpers.PrintServiceStackTypeVersionSelector(ctx, cmdData.UxBlocks, cmdData.RestApiClient, + uxHelpers.PrintServiceStackTypeVersionSelectorWithServiceStackTypeIdFilter(serviceStackType), + ) + if err != nil { + return err + } + } + typeNameVersionId = typeNameVersion.ID + } else { + input := cmdData.Params.GetString(serviceAddArgType) + serviceStackType, err := repository.GetServiceStackTypeById(ctx, cmdData.RestApiClient, stringId.ServiceStackTypeId(input)) + if err != nil { + return err + } + typeNameVersionId = serviceStackType.Versions[0].ID + } + + mode := enum.ServiceStackModeEnumNonHa + if cmdData.Params.GetBool(serviceAddArgHa) { + mode = enum.ServiceStackModeEnumHa + } + + serviceAddResponse, err := cmdData.RestApiClient.PostServiceStack(ctx, + path.ServiceStackServiceStackTypeVersionId{ + ServiceStackTypeVersionId: typeNameVersionId, + }, + body.PostStandardServiceStack{ + ProjectId: cmdData.Project.ID, + Name: types.NewString(name), + Mode: &mode, + }, + ) + if err != nil { + return err + } + + serviceAddOutput, err := serviceAddResponse.Output() + if err != nil { + return err + } + + err = uxHelpers.ProcessCheckWithSpinner( + ctx, + cmdData.UxBlocks, + []uxHelpers.Process{{ + F: uxHelpers.CheckZeropsProcess(serviceAddOutput.Process.Id, cmdData.RestApiClient), + RunningMessage: i18n.T(i18n.ServiceAdding), + ErrorMessageMessage: i18n.T(i18n.ServiceAddFailed), + SuccessMessage: i18n.T(i18n.ServiceAdded), + }}, + ) + if err != nil { + return err + } + + return nil + }) +} diff --git a/src/cmd/service.go b/src/cmd/service.go index 3f9ab873..765c87d2 100644 --- a/src/cmd/service.go +++ b/src/cmd/service.go @@ -15,6 +15,7 @@ func serviceCmd() *cmdBuilder.Cmd { AddChildrenCmd(serviceLogCmd()). AddChildrenCmd(serviceStartCmd()). AddChildrenCmd(serviceStopCmd()). + AddChildrenCmd(serviceEnvCmd()). AddChildrenCmd(servicePushCmd()). AddChildrenCmd(serviceEnableSubdomainCmd()). AddChildrenCmd(serviceDeployCmd()) diff --git a/src/cmd/serviceEnv.go b/src/cmd/serviceEnv.go new file mode 100644 index 00000000..1ffcd413 --- /dev/null +++ b/src/cmd/serviceEnv.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/template" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmd/scope" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/entity/repository" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/enum" + "gopkg.in/yaml.v3" + + "github.com/zeropsio/zcli/src/i18n" +) + +func serviceEnvCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("env"). + Short(i18n.T(i18n.CmdDescServiceEnv)). + ScopeLevel(scope.Service). + Arg(scope.ServiceArgName, cmdBuilder.OptionalArg()). + StringFlag("format", "env", i18n.T(i18n.ServiceEnvFormatFlag)). + BoolFlag("no-secrets", false, i18n.T(i18n.ServiceEnvNoSecretsFlag)). + HelpFlag(i18n.T(i18n.CmdHelpServiceEnv)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + var userDataSetup repository.GetUserDataSetup + if cmdData.Params.GetBool("no-secrets") { + userDataSetup.EsFilters(func(filter body.EsFilter) body.EsFilter { + filter.Search = append(filter.Search, body.EsSearchItem{ + Name: "type", + Operator: "ne", + Value: types.String(enum.UserDataTypeEnumSecret), + }) + return filter + }) + } + + userData, err := repository.GetUserDataByProjectId( + ctx, + cmdData.RestApiClient, + cmdData.Project, + userDataSetup, + ) + if err != nil { + return err + } + + allEnvs := make(map[string]entity.UserData, len(userData)) + for _, env := range userData { + allEnvs[fmt.Sprintf("%s_%s", env.ServiceName, env.Key)] = env + } + + envs := make(map[string]entity.UserData) + for key, env := range allEnvs { + c := env.Content.Native() + c = os.Expand(c, func(s string) string { + e, ok := allEnvs[s] + if ok { + return e.Content.Native() + } + return s + }) + env.Content = types.NewText(c) + if env.ServiceId == cmdData.Service.ID { + envs[key] = env + } + } + + format := cmdData.Params.GetString("format") + formatSplit := strings.SplitN(format, "=", 2) + formatKind := formatSplit[0] + + switch formatKind { + case "json": + enc := json.NewEncoder(cmdData.Stdout) + enc.SetIndent("", "\t") + out := make(map[string]string, len(envs)) + for _, e := range envs { + out[e.Key.Native()] = e.Content.Native() + } + if err := enc.Encode(out); err != nil { + return err + } + case "yaml": + enc := yaml.NewEncoder(cmdData.Stdout) + out := make(map[string]string, len(envs)) + for _, e := range envs { + out[e.Key.Native()] = e.Content.Native() + } + if err := enc.Encode(out); err != nil { + return err + } + case "value": + for _, env := range envs { + cmdData.Stdout.Println(env.Content) + } + case "go-template": + if len(formatSplit) < 2 { + return errors.New(i18n.T(i18n.ServiceEnvNoTemplateData)) + } + formatTemplate := formatSplit[1] + t, err := template.New("go").Parse(formatTemplate + "\n") + if err != nil { + return err + } + for _, value := range envs { + if err := t.Execute(cmdData.Stdout, value); err != nil { + return err + } + } + case "env": + for _, env := range envs { + cmdData.Stdout.Printf("%s=%s\n", env.Key, env.Content) + } + default: + return errors.New(i18n.T(i18n.ServiceEnvInvalidFormatKind, formatKind)) + } + + return nil + }) +} diff --git a/src/entity/repository/serviceStackType.go b/src/entity/repository/serviceStackType.go new file mode 100644 index 00000000..e0ae4606 --- /dev/null +++ b/src/entity/repository/serviceStackType.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "errors" + "sort" + + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" + "github.com/zeropsio/zerops-go/errorCode" + "github.com/zeropsio/zerops-go/types/stringId" +) + +func GetServiceStackTypes( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, +) (result []entity.ServiceStackType, _ error) { + settings, err := restApiClient.GetSettings(ctx) + if err != nil { + return nil, err + } + settingsOutput, err := settings.Output() + if err != nil { + return nil, err + } + for _, serviceStackType := range settingsOutput.ServiceStackList { + e := entity.ServiceStackType{ + ID: serviceStackType.Id, + Name: serviceStackType.Name, + } + + for _, serviceStackTypeVersion := range serviceStackType.ServiceStackTypeVersionList { + if !serviceStackTypeVersion.Status.IsActive() { + continue + } + if serviceStackTypeVersion.IsBuild.Native() { + continue + } + if serviceStackTypeVersion.Name.Native() == "prepare_runtime" { + continue + } + e.Versions = append(e.Versions, entity.ServiceStackTypeVersion{ + ID: serviceStackTypeVersion.Id, + Name: serviceStackTypeVersion.Name, + ExactVersionNumber: serviceStackTypeVersion.ExactVersionNumber, + }) + } + if len(e.Versions) == 0 { + continue + } + result = append(result, e) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name.Native() < result[j].Name.Native() }) + return result, nil +} + +func GetServiceStackTypeById( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + serviceStackTypeId stringId.ServiceStackTypeId, +) (result entity.ServiceStackType, _ error) { + serviceStackTypes, err := GetServiceStackTypes(ctx, restApiClient) + if err != nil { + return result, err + } + for _, serviceStackType := range serviceStackTypes { + if serviceStackType.ID == serviceStackTypeId { + return serviceStackType, nil + } + if serviceStackType.Name.Native() == serviceStackTypeId.Native() { + return serviceStackType, nil + } + for _, serviceStackTypeVersion := range serviceStackType.Versions { + if serviceStackTypeVersion.ID.Native() == serviceStackTypeId.Native() { + return serviceStackType, nil + } + if serviceStackTypeVersion.Name.Native() == serviceStackTypeId.Native() { + return serviceStackType, nil + } + } + } + return result, errors.New(string(errorCode.ServiceStackTypeVersionNotFound)) +} diff --git a/src/entity/repository/userData.go b/src/entity/repository/userData.go new file mode 100644 index 00000000..87400927 --- /dev/null +++ b/src/entity/repository/userData.go @@ -0,0 +1,167 @@ +package repository + +import ( + "context" + + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/errorsx" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/options" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" + "github.com/zeropsio/zerops-go/apiError" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/dto/output" + "github.com/zeropsio/zerops-go/errorCode" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/uuid" +) + +func GetUserDataByServiceIdOrName( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + projectId uuid.ProjectId, + serviceIdOrName string, +) ([]entity.UserData, error) { + project, err := GetProjectById(ctx, restApiClient, projectId) + if err != nil { + return nil, err + } + service, err := GetServiceById(ctx, restApiClient, uuid.ServiceStackId(serviceIdOrName)) + if err != nil { + if errorsx.Is(err, errorsx.Or( + errorsx.ErrorCode(errorCode.InvalidUserInput), + errorsx.ErrorCode(errorCode.ServiceStackNotFound), + )) { + service, err = GetServiceByName(ctx, restApiClient, projectId, types.String(serviceIdOrName)) + if err != nil { + return nil, errorsx.Convert( + err, + errorsx.ErrorCode(errorCode.ServiceStackNotFound, errorsx.ErrorCodeErrorMessage( + func(_ apiError.Error) string { + return i18n.T(i18n.ErrorServiceNotFound, serviceIdOrName) + }, + )), + ) + } + } + } + return GetUserDataByServiceId(ctx, restApiClient, project, service.ID, GetUserDataSetup{}) +} + +type EsFilterFunc func(body.EsFilter) body.EsFilter +type EsFilterFuncs []EsFilterFunc + +func (fs EsFilterFuncs) apply(esFilter body.EsFilter) body.EsFilter { + for _, f := range fs { + esFilter = f(esFilter) + } + return esFilter +} + +type GetUserDataSetup struct { + filters EsFilterFuncs +} + +func UserDataSetup( + opts ...options.Option[GetUserDataSetup], +) GetUserDataSetup { + return options.ApplyOptions(opts...) +} + +func WithUserDataEsFilters(filters ...EsFilterFunc) options.Option[GetUserDataSetup] { + return func(s *GetUserDataSetup) { + s.filters = append(s.filters, filters...) + } +} + +func (s *GetUserDataSetup) EsFilters(filters ...EsFilterFunc) { + s.filters = append(s.filters, filters...) +} + +func GetUserDataByProjectId( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + project *entity.Project, + setup GetUserDataSetup, +) ([]entity.UserData, error) { + esFilter := body.EsFilter{ + Search: []body.EsSearchItem{ + { + Name: "projectId", + Operator: "eq", + Value: project.ID.TypedString(), + }, + { + Name: "clientId", + Operator: "eq", + Value: project.OrgId.TypedString(), + }, + }, + } + + return getEsSearchUserData(ctx, restApiClient, setup.filters.apply(esFilter)) +} + +func GetUserDataByServiceId( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + project *entity.Project, + serviceId uuid.ServiceStackId, + setup GetUserDataSetup, +) ([]entity.UserData, error) { + esFilter := body.EsFilter{ + Search: []body.EsSearchItem{ + { + Name: "projectId", + Operator: "eq", + Value: project.ID.TypedString(), + }, + { + Name: "clientId", + Operator: "eq", + Value: project.OrgId.TypedString(), + }, + { + Name: "serviceStackId", + Operator: "eq", + Value: serviceId.TypedString(), + }, + }, + } + + return getEsSearchUserData(ctx, restApiClient, setup.filters.apply(esFilter)) +} + +func getEsSearchUserData( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + esFilter body.EsFilter, +) ([]entity.UserData, error) { + userDataResponse, err := restApiClient.PostUserDataSearch(ctx, esFilter) + if err != nil { + return nil, err + } + + userDataOutput, err := userDataResponse.Output() + if err != nil { + return nil, err + } + + userDataResult := make([]entity.UserData, 0, len(userDataOutput.Items)) + for _, userData := range userDataOutput.Items { + userDataResult = append(userDataResult, userDataFromEsSearch(userData)) + } + + return userDataResult, nil +} + +func userDataFromEsSearch(userData output.EsUserData) entity.UserData { + return entity.UserData{ + ID: userData.Id, + ClientId: userData.ClientId, + ServiceId: userData.ServiceStackId, + ServiceName: userData.ServiceStackName, + Key: userData.Key, + Content: userData.Content, + } +} diff --git a/src/entity/serviceStackType.go b/src/entity/serviceStackType.go new file mode 100644 index 00000000..e9418a1c --- /dev/null +++ b/src/entity/serviceStackType.go @@ -0,0 +1,18 @@ +package entity + +import ( + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/stringId" +) + +type ServiceStackType struct { + ID stringId.ServiceStackTypeId + Name types.String + Versions []ServiceStackTypeVersion +} + +type ServiceStackTypeVersion struct { + ID stringId.ServiceStackTypeVersionId + Name types.String + ExactVersionNumber types.EmptyString +} diff --git a/src/entity/userData.go b/src/entity/userData.go new file mode 100644 index 00000000..4bc5acc0 --- /dev/null +++ b/src/entity/userData.go @@ -0,0 +1,15 @@ +package entity + +import ( + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/uuid" +) + +type UserData struct { + ID uuid.UserDataId + ClientId uuid.ClientId + ServiceId uuid.ServiceStackId + ServiceName types.String + Key types.String + Content types.Text +} diff --git a/src/i18n/en.go b/src/i18n/en.go index 96837a2e..08ca9e40 100644 --- a/src/i18n/en.go +++ b/src/i18n/en.go @@ -77,6 +77,13 @@ and your %s.`, CmdDescProjectServiceImport: "Creates one or more Zerops services in an existing project.", ServiceImported: "service(s) imported", + // project service add + CmdHelpProjectServiceAdd: "the add Zerops service command.", + CmdDescProjectServiceAdd: "Creates one Zerops services in an existing project.", + ServiceAdded: "service added", + ServiceAdding: "service adding", + ServiceAddFailed: "service add failed", + // service CmdHelpService: "the service command.", CmdDescService: "Zerops service commands group", @@ -89,7 +96,7 @@ and your %s.`, ServiceStarted: "Service was started", // service stop - CmdHelpServiceStop: "the enable Zerops subdomain command.", + CmdHelpServiceStop: "the stop Zerops service command.", CmdDescServiceStop: "Starts the Zerops service.", ServiceStopping: "Service is being stopped", ServiceStopFailed: "Service stop failed", @@ -102,6 +109,13 @@ and your %s.`, ServiceDeleteFailed: "Service deletion failed", ServiceDeleted: "Service was deleted", + // service env + CmdHelpServiceEnv: "the service env command.", + CmdDescServiceEnv: "Prints expanded envs of service.", + ServiceEnvNoTemplateData: "No format data supplied, see --help", + ServiceEnvInvalidFormatKind: "Invalid format kind %s", + + // service log CmdHelpServiceLog: "the service log command.", CmdDescServiceLog: "Get service runtime or build log to stdout.", @@ -226,26 +240,30 @@ at https://docs.zerops.io/references/cli for further details.`, VpnWgQuickIsNotInstalledWindows: "wireguard is not installed, please visit https://www.wireguard.com/install/", // flags description - RegionFlag: "Choose one of Zerops regions. Use the \"zcli region list\" command to list all Zerops regions.", - RegionUrlFlag: "Zerops region file url.", - BuildVersionName: "Adds a custom version name. Automatically filled if the VERSIONNAME environment variable exists.", - BuildWorkingDir: "Sets a custom working directory. Default working directory is the current directory.", - BuildArchiveFilePath: "If set, zCLI creates a tar.gz archive with the application code in the required path relative\nto the working directory. By default, no archive is created.", - ZeropsYamlLocation: "Sets a custom path to the zerops.yml file relative to the working directory. By default zCLI\nlooks for zerops.yml in the working directory.", - UploadGitFolder: "If set, zCLI the .git folder is also uploaded. By default, the .git folder is ignored.", - OrgIdFlag: "If you have access to more than one organization, you must specify the org ID for which the\nproject is to be created.", - LogLimitFlag: "How many of the most recent log messages will be returned. Allowed interval is <1;1000>.\nDefault value = 100.", - LogMinSeverityFlag: "Returns log messages with requested or higher severity. Set either severity number in the interval\n<0;7> or one of following severity codes:\nEMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFORMATIONAL, DEBUG.", - LogMsgTypeFlag: "Select either APPLICATION or WEBSERVER log messages to be returned. Default value = APPLICATION.", - LogShowBuildFlag: "If set, zCLI will return build log messages instead of runtime log messages.", - LogFollowFlag: "If set, zCLI will continuously poll for new log messages. By default, the command will exit\nonce there are no more logs to display. To exit from this mode, use Control-C.", - LogFormatFlag: "The format of returned log messages. Following formats are supported: \nFULL: This is the default format. Messages will be returned in the complete Syslog format. \nSHORT: Returns only timestamp and log message.\nJSON: Messages will be returned as one JSON object.\nJSONSTREAM: Messages will be returned as stream of JSON objects.", - LogFormatTemplateFlag: "Set a custom log format. Can be used only with --format=FULL.\nExample: --formatTemplate=\"{{.timestamp}} {{.severity}} {{.facility}} {{.message}}\".\nSupports standard GoLang template format and functions.", - ConfirmFlag: "If set, zCLI will not ask for confirmation of destructive operations.", - ServiceIdFlag: "If you have access to more than one service, you must specify the service ID for which the\ncommand is to be executed.", - ProjectIdFlag: "If you have access to more than one project, you must specify the project ID for which the\ncommand is to be executed.", - VpnAutoDisconnectFlag: "If set, zCLI will automatically disconnect from the VPN if it is already connected.", - ZeropsYamlSetup: "Choose setup to be used from zerops.yml.", + RegionFlag: "Choose one of Zerops regions. Use the \"zcli region list\" command to list all Zerops regions.", + RegionUrlFlag: "Zerops region file url.", + BuildVersionName: "Adds a custom version name. Automatically filled if the VERSIONNAME environment variable exists.", + BuildWorkingDir: "Sets a custom working directory. Default working directory is the current directory.", + BuildArchiveFilePath: "If set, zCLI creates a tar.gz archive with the application code in the required path relative\nto the working directory. By default, no archive is created.", + ZeropsYamlLocation: "Sets a custom path to the zerops.yml file relative to the working directory. By default zCLI\nlooks for zerops.yml in the working directory.", + UploadGitFolder: "If set, zCLI the .git folder is also uploaded. By default, the .git folder is ignored.", + OrgIdFlag: "If you have access to more than one organization, you must specify the org ID for which the\nproject is to be created.", + LogLimitFlag: "How many of the most recent log messages will be returned. Allowed interval is <1;1000>.\nDefault value = 100.", + LogMinSeverityFlag: "Returns log messages with requested or higher severity. Set either severity number in the interval\n<0;7> or one of following severity codes:\nEMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFORMATIONAL, DEBUG.", + LogMsgTypeFlag: "Select either APPLICATION or WEBSERVER log messages to be returned. Default value = APPLICATION.", + LogShowBuildFlag: "If set, zCLI will return build log messages instead of runtime log messages.", + LogFollowFlag: "If set, zCLI will continuously poll for new log messages. By default, the command will exit\nonce there are no more logs to display. To exit from this mode, use Control-C.", + LogFormatFlag: "The format of returned log messages. Following formats are supported: \nFULL: This is the default format. Messages will be returned in the complete Syslog format. \nSHORT: Returns only timestamp and log message.\nJSON: Messages will be returned as one JSON object.\nJSONSTREAM: Messages will be returned as stream of JSON objects.", + LogFormatTemplateFlag: "Set a custom log format. Can be used only with --format=FULL.\nExample: --formatTemplate=\"{{.timestamp}} {{.severity}} {{.facility}} {{.message}}\".\nSupports standard GoLang template format and functions.", + ConfirmFlag: "If set, zCLI will not ask for confirmation of destructive operations.", + ServiceIdFlag: "If you have access to more than one service, you must specify the service ID for which the\ncommand is to be executed.", + ProjectIdFlag: "If you have access to more than one project, you must specify the project ID for which the\ncommand is to be executed.", + ServiceAddTypeFlag: "If set, zCLI will add new service with given type.", + ServiceAddHaFlag: "If set, zCLI will add new service with HA mode enabled.", + ServiceEnvFormatFlag: "Format of env output, possible values [env, json, yaml, value, go-template].\nWhen choosing format 'go-template' supply it with template data like --format 'go-template=export -p {{.Key}}={{.Content}}'.\nUseful template values {{.Key}} {{.Content}} {{.ServiceName}} {{.ServiceId}} {{.ClientId}}", + ServiceEnvNoSecretsFlag: "Don't include secrets in command output.", + VpnAutoDisconnectFlag: "If set, zCLI will automatically disconnect from the VPN if it is already connected.", + ZeropsYamlSetup: "Choose setup to be used from zerops.yml.", // archiveClient ArchClientWorkingDirectory: "working directory: %s", @@ -297,17 +315,21 @@ at https://docs.zerops.io/references/cli for further details.`, ArgsTooManyArgs: "expected no more than %d arg(s), got %d", // ux helpers - ProjectSelectorListEmpty: "You don't have any projects yet. Create a new project using `zcli project import` command.", - ProjectSelectorPrompt: "Please, select a project", - ProjectSelectorOutOfRangeError: "We couldn't find a project with the index you entered. Please, try again or contact our support team.", - ServiceSelectorListEmpty: "Project doesn't have any services yet. Create a new service using `zcli service import` command", - ServiceSelectorPrompt: "Please, select a service", - ServiceSelectorOutOfRangeError: "We couldn't find a service with the index you entered. Please, try again or contact our support team.", - OrgSelectorListEmpty: "You don't belong to any organization yet. Please, contact our support team.", - OrgSelectorPrompt: "Please, select an org", - OrgSelectorOutOfRangeError: "We couldn't find an org with the index you entered. Please, try again or contact our support team.", - SelectorAllowedOnlyInTerminal: "Interactive selection can be used only in terminal mode. Use command flags to specify missing parameters.", - PromptAllowedOnlyInTerminal: "Interactive prompt can be used only in terminal mode. Use --confirm=true flag to confirm it", + ProjectSelectorListEmpty: "You don't have any projects yet. Create a new project using `zcli project import` command.", + ProjectSelectorPrompt: "Please, select a project", + ProjectSelectorOutOfRangeError: "We couldn't find a project with the index you entered. Please, try again or contact our support team.", + ServiceSelectorListEmpty: "Project doesn't have any services yet. Create a new service using `zcli service import` command", + ServiceSelectorPrompt: "Please, select a service", + ServiceStackTypeSelectorPrompt: "Please, select a service type", + ServiceStackTypeSelectorOutOfRangeError: "We couldn't find a service type with the index you entered. Please, try again or contact our support team.", + ServiceStackTypeVersionSelectorPrompt: "Please, select a service type version", + ServiceStackTypeVersionSelectorOutOfRangeError: "We couldn't find a service type version with the index you entered. Please, try again or contact our support team.", + ServiceSelectorOutOfRangeError: "We couldn't find a service with the index you entered. Please, try again or contact our support team.", + OrgSelectorListEmpty: "You don't belong to any organization yet. Please, contact our support team.", + OrgSelectorPrompt: "Please, select an org", + OrgSelectorOutOfRangeError: "We couldn't find an org with the index you entered. Please, try again or contact our support team.", + SelectorAllowedOnlyInTerminal: "Interactive selection can be used only in terminal mode. Use command flags to specify missing parameters.", + PromptAllowedOnlyInTerminal: "Interactive prompt can be used only in terminal mode. Use --confirm=true flag to confirm it", UnauthenticatedUser: `unauthenticated user, login before proceeding with this command zcli login {token} diff --git a/src/i18n/i18n.go b/src/i18n/i18n.go index a371a60a..e3c91050 100644 --- a/src/i18n/i18n.go +++ b/src/i18n/i18n.go @@ -77,6 +77,13 @@ const ( CmdDescProjectServiceImport = "CmdDescProjectServiceImport" ServiceImported = "ServiceImported" + // project service add + CmdHelpProjectServiceAdd = "CmdHelpProjectServiceAdd" + CmdDescProjectServiceAdd = "CmdDescProjectServiceAdd" + ServiceAdded = "ServiceAdded" + ServiceAdding = "ServiceAdding" + ServiceAddFailed = "ServiceAddFailed" + // service CmdHelpService = "CmdHelpService" CmdDescService = "CmdDescService" @@ -88,6 +95,12 @@ const ( ServiceStartFailed = "ServiceStartFailed" ServiceStarted = "ServiceStarted" + // service env + CmdDescServiceEnv = "CmdDescServiceEnv" + CmdHelpServiceEnv = "CmdHelpServiceEnv" + ServiceEnvNoTemplateData = "ServiceEnvNoTemplateData" + ServiceEnvInvalidFormatKind = "ServiceEnvInvalidFormatKind" + // service stop CmdHelpServiceStop = "CmdHelpServiceStop" CmdDescServiceStop = "CmdDescServiceStop" @@ -206,26 +219,30 @@ const ( VpnWgQuickIsNotInstalledWindows = "VpnWgQuickIsNotInstalledWindows" // flags description - RegionFlag = "RegionFlag" - RegionUrlFlag = "RegionUrlFlag" - BuildVersionName = "BuildVersionName" - BuildWorkingDir = "BuildWorkingDir" - BuildArchiveFilePath = "BuildArchiveFilePath" - ZeropsYamlLocation = "ZeropsYamlLocation" - UploadGitFolder = "UploadGitFolder" - OrgIdFlag = "OrgIdFlag" - LogLimitFlag = "LogLimitFlag" - LogMinSeverityFlag = "LogMinSeverityFlag" - LogMsgTypeFlag = "LogMsgTypeFlag" - LogFollowFlag = "LogFollowFlag" - LogShowBuildFlag = "LogShowBuildFlag" - LogFormatFlag = "LogFormatFlag" - LogFormatTemplateFlag = "LogFormatTemplateFlag" - ConfirmFlag = "ConfirmFlag" - ServiceIdFlag = "ServiceIdFlag" - ProjectIdFlag = "ProjectIdFlag" - VpnAutoDisconnectFlag = "VpnAutoDisconnectFlag" - ZeropsYamlSetup = "ZeropsYamlSetup" + RegionFlag = "RegionFlag" + RegionUrlFlag = "RegionUrlFlag" + BuildVersionName = "BuildVersionName" + BuildWorkingDir = "BuildWorkingDir" + BuildArchiveFilePath = "BuildArchiveFilePath" + ZeropsYamlLocation = "ZeropsYamlLocation" + UploadGitFolder = "UploadGitFolder" + OrgIdFlag = "OrgIdFlag" + LogLimitFlag = "LogLimitFlag" + LogMinSeverityFlag = "LogMinSeverityFlag" + LogMsgTypeFlag = "LogMsgTypeFlag" + LogFollowFlag = "LogFollowFlag" + LogShowBuildFlag = "LogShowBuildFlag" + LogFormatFlag = "LogFormatFlag" + LogFormatTemplateFlag = "LogFormatTemplateFlag" + ConfirmFlag = "ConfirmFlag" + ServiceIdFlag = "ServiceIdFlag" + ProjectIdFlag = "ProjectIdFlag" + ServiceAddTypeFlag = "ServiceAddTypeFlag" + ServiceAddHaFlag = "ServiceAddTypeHa" + ServiceEnvFormatFlag = "ServiceEnvFormatFlag" + ServiceEnvNoSecretsFlag = "ServiceEnvNoSecretsFlag" + VpnAutoDisconnectFlag = "VpnAutoDisconnectFlag" + ZeropsYamlSetup = "ZeropsYamlSetup" // archiveClient ArchClientWorkingDirectory = "ArchClientWorkingDirectory" @@ -277,17 +294,21 @@ const ( ArgsTooManyArgs = "ArgsTooManyArgs" // ux helpers - ProjectSelectorListEmpty = "ProjectSelectorListEmpty" - ProjectSelectorPrompt = "ProjectSelectorPrompt" - ProjectSelectorOutOfRangeError = "ProjectSelectorOutOfRangeError" - ServiceSelectorListEmpty = "ServiceSelectorListEmpty" - ServiceSelectorPrompt = "ServiceSelectorPrompt" - ServiceSelectorOutOfRangeError = "ServiceSelectorOutOfRangeError" - OrgSelectorListEmpty = "OrgSelectorListEmpty" - OrgSelectorPrompt = "OrgSelectorPrompt" - OrgSelectorOutOfRangeError = "OrgSelectorOutOfRangeError" - SelectorAllowedOnlyInTerminal = "SelectorAllowedOnlyInTerminal" - PromptAllowedOnlyInTerminal = "PromptAllowedOnlyInTerminal" + ProjectSelectorListEmpty = "ProjectSelectorListEmpty" + ProjectSelectorPrompt = "ProjectSelectorPrompt" + ProjectSelectorOutOfRangeError = "ProjectSelectorOutOfRangeError" + ServiceSelectorListEmpty = "ServiceSelectorListEmpty" + ServiceSelectorPrompt = "ServiceSelectorPrompt" + ServiceStackTypeSelectorPrompt = "ServiceStackTypeSelectorPrompt" + ServiceStackTypeSelectorOutOfRangeError = "ServiceStackTypeSelectorOutOfRangeError" + ServiceStackTypeVersionSelectorPrompt = "ServiceStackTypeVersionSelectorPrompt" + ServiceStackTypeVersionSelectorOutOfRangeError = "ServiceStackTypeVersionSelectorOutOfRangeError" + ServiceSelectorOutOfRangeError = "ServiceSelectorOutOfRangeError" + OrgSelectorListEmpty = "OrgSelectorListEmpty" + OrgSelectorPrompt = "OrgSelectorPrompt" + OrgSelectorOutOfRangeError = "OrgSelectorOutOfRangeError" + SelectorAllowedOnlyInTerminal = "SelectorAllowedOnlyInTerminal" + PromptAllowedOnlyInTerminal = "PromptAllowedOnlyInTerminal" UnauthenticatedUser = "UnauthenticatedUser" diff --git a/src/options/option.go b/src/options/option.go new file mode 100644 index 00000000..a85fa0a6 --- /dev/null +++ b/src/options/option.go @@ -0,0 +1,32 @@ +package options + +type OptionError[T any] func(*T) error + +func ApplyOptionsError[K any, T ~func(*K) error](in ...T) (K, []error) { + var emptyValue K + return ApplyOptionsErrorWithDefault[K](emptyValue, in...) +} + +func ApplyOptionsErrorWithDefault[K any, T ~func(*K) error](k K, in ...T) (K, []error) { + var errors []error + for _, o := range in { + if err := o(&k); err != nil { + errors = append(errors, err) + } + } + return k, errors +} + +type Option[T any] func(*T) + +func ApplyOptions[K any, T ~func(*K)](in ...T) K { + var emptyValue K + return ApplyOptionsWithDefault(emptyValue, in...) +} + +func ApplyOptionsWithDefault[K any, T ~func(*K)](k K, in ...T) K { + for _, o := range in { + o(&k) + } + return k +} diff --git a/src/printer/printer.go b/src/printer/printer.go index 8a0108fb..138d9ec9 100644 --- a/src/printer/printer.go +++ b/src/printer/printer.go @@ -24,6 +24,10 @@ func NewPrinter(out io.Writer) Printer { } } +func (p Printer) Write(d []byte) (int, error) { + return p.out.Write(d) +} + func (p Printer) Printf(format string, args ...any) { fmt.Fprintf(p.out, format, args...) } diff --git a/src/uxBlock/blocks.go b/src/uxBlock/blocks.go index e271e989..254ddaa9 100644 --- a/src/uxBlock/blocks.go +++ b/src/uxBlock/blocks.go @@ -13,8 +13,11 @@ import ( type UxBlocks interface { LogDebug(message string) PrintInfo(line styles.Line) + PrintInfoLine(text string) PrintWarning(line styles.Line) + PrintWarningLine(text string) PrintError(line styles.Line) + PrintErrorLine(text string) Table(body *TableBody, auxOptions ...TableOption) Select(ctx context.Context, tableBody *TableBody, auxOptions ...SelectOption) ([]int, error) Prompt( diff --git a/src/uxBlock/logs.go b/src/uxBlock/logs.go index 315f6503..39c2ed06 100644 --- a/src/uxBlock/logs.go +++ b/src/uxBlock/logs.go @@ -11,12 +11,30 @@ func (b *uxBlocks) PrintInfo(line styles.Line) { b.debugFileLogger.Info(line.DisableStyle()) } +func (b *uxBlocks) PrintInfoLine(text string) { + line := styles.InfoLine(text) + b.outputLogger.Info(line) + b.debugFileLogger.Info(line.DisableStyle()) +} + func (b *uxBlocks) PrintWarning(line styles.Line) { b.outputLogger.Warning(line) b.debugFileLogger.Warning(line.DisableStyle()) } +func (b *uxBlocks) PrintWarningLine(text string) { + line := styles.WarningLine(text) + b.outputLogger.Warning(line) + b.debugFileLogger.Warning(line.DisableStyle()) +} + func (b *uxBlocks) PrintError(line styles.Line) { b.outputLogger.Error(line) b.debugFileLogger.Error(line.DisableStyle()) } + +func (b *uxBlocks) PrintErrorLine(text string) { + line := styles.ErrorLine(text) + b.outputLogger.Error(line) + b.debugFileLogger.Error(line.DisableStyle()) +} diff --git a/src/uxBlock/mocks/blocks.go b/src/uxBlock/mocks/blocks.go index 045ec2d0..59bfecfc 100644 --- a/src/uxBlock/mocks/blocks.go +++ b/src/uxBlock/mocks/blocks.go @@ -60,6 +60,18 @@ func (mr *MockUxBlocksMockRecorder) PrintError(line interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrintError", reflect.TypeOf((*MockUxBlocks)(nil).PrintError), line) } +// PrintErrorLine mocks base method. +func (m *MockUxBlocks) PrintErrorLine(text string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "PrintErrorLine", text) +} + +// PrintErrorLine indicates an expected call of PrintErrorLine. +func (mr *MockUxBlocksMockRecorder) PrintErrorLine(text interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrintErrorLine", reflect.TypeOf((*MockUxBlocks)(nil).PrintErrorLine), text) +} + // PrintInfo mocks base method. func (m *MockUxBlocks) PrintInfo(line styles.Line) { m.ctrl.T.Helper() @@ -72,6 +84,18 @@ func (mr *MockUxBlocksMockRecorder) PrintInfo(line interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrintInfo", reflect.TypeOf((*MockUxBlocks)(nil).PrintInfo), line) } +// PrintInfoLine mocks base method. +func (m *MockUxBlocks) PrintInfoLine(text string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "PrintInfoLine", text) +} + +// PrintInfoLine indicates an expected call of PrintInfoLine. +func (mr *MockUxBlocksMockRecorder) PrintInfoLine(text interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrintInfoLine", reflect.TypeOf((*MockUxBlocks)(nil).PrintInfoLine), text) +} + // PrintWarning mocks base method. func (m *MockUxBlocks) PrintWarning(line styles.Line) { m.ctrl.T.Helper() @@ -84,6 +108,18 @@ func (mr *MockUxBlocksMockRecorder) PrintWarning(line interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrintWarning", reflect.TypeOf((*MockUxBlocks)(nil).PrintWarning), line) } +// PrintWarningLine mocks base method. +func (m *MockUxBlocks) PrintWarningLine(text string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "PrintWarningLine", text) +} + +// PrintWarningLine indicates an expected call of PrintWarningLine. +func (mr *MockUxBlocksMockRecorder) PrintWarningLine(text interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrintWarningLine", reflect.TypeOf((*MockUxBlocks)(nil).PrintWarningLine), text) +} + // Prompt mocks base method. func (m *MockUxBlocks) Prompt(ctx context.Context, message string, choices []string, auxOptions ...uxBlock.PromptOption) (int, error) { m.ctrl.T.Helper() diff --git a/src/uxBlock/select.go b/src/uxBlock/select.go index bbdcec25..9984e01f 100644 --- a/src/uxBlock/select.go +++ b/src/uxBlock/select.go @@ -199,5 +199,5 @@ func (m *selectModel) View() string { t.Width(calculateTableWidth(t, m.uxBlocks.terminalWidth)) - return s + t.String() + return s + t.String() + "\n" } diff --git a/src/uxHelpers/serviceStackType.go b/src/uxHelpers/serviceStackType.go new file mode 100644 index 00000000..8b1686bc --- /dev/null +++ b/src/uxHelpers/serviceStackType.go @@ -0,0 +1,31 @@ +package uxHelpers + +import ( + "context" + + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/entity/repository" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" +) + +func PrintServiceStackTypeSelector( + ctx context.Context, + uxBlocks uxBlock.UxBlocks, + restApiClient *zeropsRestApiClient.Handler, +) (result entity.ServiceStackType, _ error) { + serviceStackTypes, err := repository.GetServiceStackTypes(ctx, restApiClient) + if err != nil { + return result, err + } + + return SelectOne(ctx, uxBlocks, serviceStackTypes, + SelectOneWithHeader[entity.ServiceStackType]("ID", "Name"), + SelectOneWithSelectLabel[entity.ServiceStackType](i18n.T(i18n.ServiceStackTypeSelectorPrompt)), + SelectOneWithNotFound[entity.ServiceStackType](i18n.T(i18n.ServiceStackTypeSelectorOutOfRangeError)), + SelectOneWithRow(func(in entity.ServiceStackType) []string { + return []string{string(in.ID), in.Name.String()} + }), + ) +} diff --git a/src/uxHelpers/serviceStackTypeVersion.go b/src/uxHelpers/serviceStackTypeVersion.go new file mode 100644 index 00000000..72b46a4c --- /dev/null +++ b/src/uxHelpers/serviceStackTypeVersion.go @@ -0,0 +1,132 @@ +package uxHelpers + +import ( + "context" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/entity/repository" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/options" + "github.com/zeropsio/zcli/src/uxBlock" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" +) + +type printServiceStackTypeVersionSelector struct { + filters []func(entity.ServiceStackType, entity.ServiceStackTypeVersion) bool +} + +func PrintServiceStackTypeVersionSelectorWithServiceStackTypeIdFilter(in entity.ServiceStackType) options.Option[printServiceStackTypeVersionSelector] { + return func(p *printServiceStackTypeVersionSelector) { + p.filters = append(p.filters, func(t entity.ServiceStackType, _ entity.ServiceStackTypeVersion) bool { + return t.ID == in.ID + }) + } +} + +func PrintServiceStackTypeVersionSelector( + ctx context.Context, + uxBlocks uxBlock.UxBlocks, + restApiClient *zeropsRestApiClient.Handler, + opts ...options.Option[printServiceStackTypeVersionSelector], +) (entity.ServiceStackTypeVersion, error) { + setup := options.ApplyOptions(opts...) + + list, err := repository.GetServiceStackTypes(ctx, restApiClient) + if err != nil { + return entity.ServiceStackTypeVersion{}, err + } + var versionList []entity.ServiceStackTypeVersion + for _, typeItem := range list { + for _, versionItem := range typeItem.Versions { + var skipVersion bool + for _, filter := range setup.filters { + if !filter(typeItem, versionItem) { + skipVersion = true + break + } + } + if skipVersion { + continue + } + versionList = append(versionList, versionItem) + } + } + + return SelectOne(ctx, uxBlocks, versionList, + SelectOneWithHeader[entity.ServiceStackTypeVersion]("ID", "Name"), + SelectOneWithSelectLabel[entity.ServiceStackTypeVersion](i18n.T(i18n.ServiceStackTypeVersionSelectorPrompt)), + SelectOneWithNotFound[entity.ServiceStackTypeVersion](i18n.T(i18n.ServiceStackTypeVersionSelectorOutOfRangeError)), + SelectOneWithRow(func(in entity.ServiceStackTypeVersion) []string { + return []string{string(in.ID), in.Name.String()} + }), + ) +} + +type selectOneConfig[T any] struct { + Header []string + Row func(T) []string + SelectLabel string + NotFound string +} + +func SelectOneWithSelectLabel[T any](label string) options.Option[selectOneConfig[T]] { + return func(s *selectOneConfig[T]) { + s.SelectLabel = label + } +} + +func SelectOneWithHeader[T any](columns ...string) options.Option[selectOneConfig[T]] { + return func(s *selectOneConfig[T]) { + s.Header = columns + } +} + +func SelectOneWithRow[T any](in func(T) []string) options.Option[selectOneConfig[T]] { + return func(s *selectOneConfig[T]) { + s.Row = in + } +} + +func SelectOneWithNotFound[T any](label string) options.Option[selectOneConfig[T]] { + return func(s *selectOneConfig[T]) { + s.NotFound = label + } +} + +func SelectOne[T any](ctx context.Context, uxBlocks uxBlock.UxBlocks, list []T, opts ...options.Option[selectOneConfig[T]]) (result T, _ error) { + setup := options.ApplyOptions(opts...) + + if len(list) == 0 { + uxBlocks.PrintWarning(styles.WarningLine(setup.NotFound)) + return result, errors.New(setup.NotFound) + } + header := (&uxBlock.TableRow{}).AddStringCells(setup.Header...) + + tableBody := &uxBlock.TableBody{} + for _, listItem := range list { + tableBody.AddStringsRow(setup.Row(listItem)...) + } + + listIndex, err := uxBlocks.Select( + ctx, + tableBody, + uxBlock.SelectLabel(setup.SelectLabel), + uxBlock.SelectTableHeader(header), + ) + + if err != nil { + return result, err + } + + if len(listIndex) == 0 { + return result, errors.New(setup.NotFound) + } + + if listIndex[0] > len(list)-1 { + return result, errors.New(setup.NotFound) + } + + return list[listIndex[0]], nil +}