diff --git a/src/agent/assets/CLAUDE.md b/src/agent/assets/CLAUDE.md new file mode 100644 index 00000000..6af17b25 --- /dev/null +++ b/src/agent/assets/CLAUDE.md @@ -0,0 +1,101 @@ +# zcli - Zerops CLI Reference + +## IMPORTANT +Always explore actual source code - docs/help may be outdated. +Verify commands by checking `src/cmd/*.go` files. + +## Config +```yaml +# .zcli.yml (project root) +projectId: +serviceId: +``` +Env vars: `ZEROPS_TOKEN`, `ZEROPS_PROJECTID` + +## Common Flags +| Flag | Short | Description | +|------|-------|-------------| +| `--project-id` | `-P` | Project UUID | +| `--service-id` | `-S` | Service UUID | +| `--confirm` | | Skip prompts | +| `--output` | `-o` | Output format (table/json) | + +## Auth +```sh +zcli login # Auth with token +zcli logout # Disconnect VPN + logout +``` + +## VPN +```sh +zcli vpn up [project-id] # Connect (flags: --auto-disconnect, --mtu) +zcli vpn down # Disconnect +``` + +## Project +```sh +zcli project list +zcli project create --name --org-id [--mode light|serious] [--tags t1,t2] +zcli project delete [--confirm] +zcli project env [-P id] [--service ] [--export] [--user-only] +zcli project scope # Set default project +zcli project scope --clear # Clear scope +zcli project project-import [--org-id ] +zcli project service-import [-P id] +``` + +## Service +```sh +zcli service list [-P id] +zcli service create --name [-P id] [--mode ha|non_ha] [--env K=V] [--env-file f] +zcli service delete [--confirm] +zcli service start +zcli service stop +zcli service enable-subdomain +``` + +## Deploy +```sh +zcli push [-P id] # Shorthand for service push +zcli service push [flags] + --working-dir # Source dir (default: ./) + --zerops-yaml-path # Custom zerops.yml path + --setup # zerops.yml setup to use + --version-name # Custom version name + -w, --workspace-state # clean|staged|all (default: all) + -g, --deploy-git-folder # Include .git + --no-git # Upload dir as-is + --archive-file-path # Create archive + --disable-logs # Suppress output +``` + +## Logs +```sh +zcli service log [-S id] [--follow] [--limit N] [--show-build-logs] + --format FULL|SHORT|JSON|JSONSTREAM + --message-type APPLICATION|WEBSERVER + --minimum-severity +``` + +## Utility +```sh +zcli version +zcli env # Show global env vars +zcli show-debug-logs +zcli completion {bash|zsh|fish|powershell} +``` + +## Workflows +```sh +# Deploy to specific service +zcli project scope && zcli push + +# Quick deploy with scope +zcli push -P + +# Export project env vars +eval "$(zcli project env -P --export)" + +# View live logs +zcli service log -S --follow +``` diff --git a/src/cmd/agent.go b/src/cmd/agent.go new file mode 100644 index 00000000..3930c058 --- /dev/null +++ b/src/cmd/agent.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" +) + +func agentCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("agent"). + Short(i18n.T(i18n.CmdDescAgent)). + HelpFlag(i18n.T(i18n.CmdHelpAgent)). + AddChildrenCmd(agentInitCmd()) +} diff --git a/src/cmd/agentInit.go b/src/cmd/agentInit.go new file mode 100644 index 00000000..a17bc417 --- /dev/null +++ b/src/cmd/agentInit.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/output" +) + +const defaultAgentURL = "https://raw.githubusercontent.com/krls2020/zcli/main/src/agent/assets/CLAUDE.md" +const defaultOutputPath = "CLAUDE.md" + +// agentInitResultJSON represents JSON output for agent init command +type agentInitResultJSON struct { + SourceURL string `json:"sourceUrl"` + OutputFile string `json:"outputFile"` + Status string `json:"status"` +} + +func agentInitCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("init"). + Short(i18n.T(i18n.CmdDescAgentInit)). + HelpFlag(i18n.T(i18n.CmdHelpAgentInit)). + StringFlag("url", defaultAgentURL, i18n.T(i18n.AgentInitFlagUrl)). + StringFlag("file", defaultOutputPath, i18n.T(i18n.AgentInitFlagFile)). + GuestRunFunc(func(ctx context.Context, cmdData *cmdBuilder.GuestCmdData) error { + url := cmdData.Params.GetString("url") + outputPath := cmdData.Params.GetString("file") + outputFormat := cmdData.Params.GetString("output") + + if !output.IsJSON(outputFormat) { + cmdData.UxBlocks.PrintInfoText(i18n.T(i18n.AgentInitFetching, url)) + } + + // Fetch the content from URL with timeout + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := httpClient.Get(url) + if err != nil { + return errors.Wrap(err, i18n.T(i18n.AgentInitFetchFailed, url)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.Errorf(i18n.T(i18n.AgentInitFetchFailed, url)+": HTTP %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrapf(err, "failed to read response body") + } + + // Ensure directory exists + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return errors.Wrapf(err, "failed to create directory %s", dir) + } + + // Write to file + if err := os.WriteFile(outputPath, content, 0644); err != nil { + return errors.Wrap(err, i18n.T(i18n.AgentInitWriteFailed, outputPath)) + } + + // JSON output + if output.IsJSON(outputFormat) { + result := agentInitResultJSON{ + SourceURL: url, + OutputFile: outputPath, + Status: "success", + } + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + cmdData.UxBlocks.PrintSuccessText(i18n.T(i18n.AgentInitSaved, outputPath)) + + return nil + }) +} diff --git a/src/cmd/discovery.go b/src/cmd/discovery.go new file mode 100644 index 00000000..60b6dfd9 --- /dev/null +++ b/src/cmd/discovery.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" +) + +func discoveryCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("discovery"). + Short(i18n.T(i18n.CmdDescDiscovery)). + HelpFlag(i18n.T(i18n.CmdHelpDiscovery)). + AddChildrenCmd(discoveryListCmd()) +} diff --git a/src/cmd/discoveryList.go b/src/cmd/discoveryList.go new file mode 100644 index 00000000..5fc89562 --- /dev/null +++ b/src/cmd/discoveryList.go @@ -0,0 +1,333 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "github.com/pkg/errors" + "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/output" + "github.com/zeropsio/zcli/src/uxBlock/models/table" + "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/uuid" +) + +// Discovery output types +type discoveryOutput struct { + Clients []discoveryClient `json:"clients"` + Projects []discoveryProject `json:"projects"` + Services []discoveryService `json:"services"` + ServiceTypes []serviceTypeGroup `json:"serviceTypes,omitempty"` +} + +type discoveryClient struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` +} + +type discoveryProject struct { + ID string `json:"id"` + Name string `json:"name"` + OrgID string `json:"orgId"` + Status string `json:"status"` + Mode string `json:"mode"` +} + +type discoveryService struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + EnvVarNames []string `json:"envVarNames,omitempty"` +} + +func discoveryListCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("list"). + Short(i18n.T(i18n.CmdDescDiscoveryList)). + HelpFlag(i18n.T(i18n.CmdHelpDiscoveryList)). + StringFlag("project-id", "", i18n.T(i18n.DiscoveryFlagProjectId), cmdBuilder.ShortHand("p")). + StringFlag("service-id", "", i18n.T(i18n.DiscoveryFlagServiceId), cmdBuilder.ShortHand("s")). + BoolFlag("include-service-types", false, i18n.T(i18n.DiscoveryFlagIncludeServiceTypes)). + BoolFlag("include-envs", false, i18n.T(i18n.DiscoveryFlagIncludeEnvs)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + projectIdFilter := cmdData.Params.GetString("project-id") + serviceIdFilter := cmdData.Params.GetString("service-id") + includeServiceTypes := cmdData.Params.GetBool("include-service-types") + includeEnvs := cmdData.Params.GetBool("include-envs") + outputFormat := cmdData.Params.GetString("output") + + // Fetch all orgs (clients) + orgs, err := repository.GetAllOrgs(ctx, cmdData.RestApiClient) + if err != nil { + return errors.Wrap(err, "failed to fetch organizations") + } + + // Fetch all projects + allProjects, err := repository.GetAllProjects(ctx, cmdData.RestApiClient) + if err != nil { + return errors.Wrap(err, "failed to fetch projects") + } + + // Apply project filter if specified + var projects []entity.Project + if projectIdFilter != "" { + for _, p := range allProjects { + if string(p.Id) == projectIdFilter { + projects = append(projects, p) + break + } + } + } else { + projects = allProjects + } + + // Fetch services for each project + var services []discoveryService + for _, project := range projects { + projectServices, err := repository.GetNonSystemServicesByProject(ctx, cmdData.RestApiClient, project) + if err != nil { + return errors.Wrapf(err, "failed to fetch services for project %s", project.Name.String()) + } + + for _, svc := range projectServices { + // Apply service filter if specified + if serviceIdFilter != "" && string(svc.Id) != serviceIdFilter { + continue + } + + ds := discoveryService{ + ID: string(svc.Id), + ProjectID: string(svc.ProjectId), + Name: svc.Name.String(), + Type: buildServiceType(svc), + Status: svc.Status.String(), + } + + // Optionally fetch env var names + if includeEnvs { + envNames, err := fetchEnvVarNames(ctx, cmdData, svc.Id) + if err != nil { + // Log error but continue - env vars are optional + ds.EnvVarNames = []string{} + } else { + ds.EnvVarNames = envNames + } + } + + services = append(services, ds) + } + } + + // Build discovery output + result := discoveryOutput{ + Clients: buildClientsOutput(orgs), + Projects: buildProjectsOutput(projects), + Services: services, + } + + // Optionally fetch service types + if includeServiceTypes { + serviceTypes, err := fetchServiceTypes(ctx, cmdData) + if err != nil { + return errors.Wrap(err, "failed to fetch service types") + } + result.ServiceTypes = serviceTypes + } + + // Output based on format + if output.IsJSON(outputFormat) { + return outputDiscoveryJSON(cmdData, result) + } + + return outputDiscoveryTable(cmdData, result) + }) +} + +func buildServiceType(svc entity.Service) string { + typeName := string(svc.ServiceTypeId) + version := svc.ServiceStackTypeVersionName.String() + if version != "" { + return typeName + "@" + version + } + return typeName +} + +func buildClientsOutput(orgs []entity.Org) []discoveryClient { + clients := make([]discoveryClient, 0, len(orgs)) + for _, org := range orgs { + clients = append(clients, discoveryClient{ + ID: string(org.Id), + Name: org.Name.String(), + Role: org.Role.String(), + }) + } + return clients +} + +func buildProjectsOutput(projects []entity.Project) []discoveryProject { + result := make([]discoveryProject, 0, len(projects)) + for _, p := range projects { + result = append(result, discoveryProject{ + ID: string(p.Id), + Name: p.Name.String(), + OrgID: string(p.OrgId), + Status: p.Status.String(), + Mode: p.Mode.String(), + }) + } + return result +} + +func fetchEnvVarNames(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData, serviceId uuid.ServiceStackId) ([]string, error) { + // Fetch service details to get user data list + serviceResponse, err := cmdData.RestApiClient.GetServiceStack(ctx, path.ServiceStackId{Id: serviceId}) + if err != nil { + return nil, err + } + + serviceOutput, err := serviceResponse.Output() + if err != nil { + return nil, err + } + + // Extract env var names from the service's user data + var envNames []string + for _, userData := range serviceOutput.UserData { + envNames = append(envNames, userData.Key.String()) + } + sort.Strings(envNames) + return envNames, nil +} + +func fetchServiceTypes(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) ([]serviceTypeGroup, error) { + esFilter := body.EsFilter{ + Limit: types.NewIntNull(500), + } + + response, err := cmdData.RestApiClient.PostServiceStackTypeSearch(ctx, esFilter) + if err != nil { + return nil, err + } + + searchOutput, err := response.Output() + if err != nil { + return nil, err + } + + return groupServiceTypesFromOutput(searchOutput.Items), nil +} + +func outputDiscoveryJSON(cmdData *cmdBuilder.LoggedUserCmdData, result discoveryOutput) error { + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} + +func outputDiscoveryTable(cmdData *cmdBuilder.LoggedUserCmdData, result discoveryOutput) error { + cmdData.Stdout.Println("INFRASTRUCTURE DISCOVERY") + cmdData.Stdout.Println("") + cmdData.Stdout.Println(fmt.Sprintf("Organizations: %d", len(result.Clients))) + cmdData.Stdout.Println(fmt.Sprintf("Projects: %d", len(result.Projects))) + cmdData.Stdout.Println(fmt.Sprintf("Services: %d", len(result.Services))) + cmdData.Stdout.Println("") + + // Organizations table + if len(result.Clients) > 0 { + cmdData.Stdout.Println("ORGANIZATIONS:") + orgHeader := table.NewRowFromStrings("ID", "NAME", "ROLE") + orgBody := table.NewBody() + for _, c := range result.Clients { + orgBody.AddStringsRow(c.ID, c.Name, c.Role) + } + cmdData.Stdout.Println(table.Render(orgBody, table.WithHeader(orgHeader))) + cmdData.Stdout.Println("") + } + + // Projects table + if len(result.Projects) > 0 { + cmdData.Stdout.Println("PROJECTS:") + + // Build project to services count map + serviceCount := make(map[string]int) + for _, svc := range result.Services { + serviceCount[svc.ProjectID]++ + } + + // Build project to org name map + orgNames := make(map[string]string) + for _, client := range result.Clients { + orgNames[client.ID] = client.Name + } + + header := table.NewRowFromStrings("ID", "NAME", "ORG", "STATUS", "SERVICES") + tableBody := table.NewBody() + + for _, p := range result.Projects { + orgName := orgNames[p.OrgID] + if orgName == "" { + orgName = "-" + } + tableBody.AddStringsRow( + p.ID, + p.Name, + truncateString(orgName, 20), + p.Status, + fmt.Sprintf("%d", serviceCount[p.ID]), + ) + } + + t := table.Render(tableBody, table.WithHeader(header)) + cmdData.Stdout.Println(t) + cmdData.Stdout.Println("") + } + + // Services table + if len(result.Services) > 0 { + cmdData.Stdout.Println("SERVICES:") + + // Build project name map + projectNames := make(map[string]string) + for _, p := range result.Projects { + projectNames[p.ID] = p.Name + } + + svcHeader := table.NewRowFromStrings("ID", "NAME", "PROJECT", "TYPE", "STATUS") + svcBody := table.NewBody() + for _, s := range result.Services { + projectName := projectNames[s.ProjectID] + if projectName == "" { + projectName = "-" + } + svcBody.AddStringsRow( + s.ID, + s.Name, + truncateString(projectName, 20), + s.Type, + s.Status, + ) + } + cmdData.Stdout.Println(table.Render(svcBody, table.WithHeader(svcHeader))) + cmdData.Stdout.Println("") + } + + cmdData.Stdout.Println("Use --output=json for full details.") + + return nil +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} diff --git a/src/cmd/envVars.go b/src/cmd/envVars.go new file mode 100644 index 00000000..31bf4b2f --- /dev/null +++ b/src/cmd/envVars.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "strings" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/i18n" +) + +// EnvVar represents a key-value pair for environment variables +type EnvVar struct { + Key string + Value string +} + +// ParseEnvVars parses KEY=VALUE pairs from arguments +func ParseEnvVars(args []string) ([]EnvVar, error) { + envVars := make([]EnvVar, 0, len(args)) + for _, arg := range args { + parts := strings.SplitN(arg, "=", 2) + if len(parts) != 2 { + return nil, errors.Errorf(i18n.T(i18n.EnvSetInvalidFormat), arg) + } + if parts[0] == "" { + return nil, errors.Errorf(i18n.T(i18n.EnvSetEmptyKey), arg) + } + envVars = append(envVars, EnvVar{ + Key: parts[0], + Value: parts[1], + }) + } + return envVars, nil +} diff --git a/src/cmd/process.go b/src/cmd/process.go new file mode 100644 index 00000000..251385e8 --- /dev/null +++ b/src/cmd/process.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" +) + +func processCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("process"). + Short(i18n.T(i18n.CmdDescProcess)). + HelpFlag(i18n.T(i18n.CmdHelpProcess)). + AddChildrenCmd(processListCmd()) +} diff --git a/src/cmd/processList.go b/src/cmd/processList.go new file mode 100644 index 00000000..84c225b8 --- /dev/null +++ b/src/cmd/processList.go @@ -0,0 +1,237 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/output" + "github.com/zeropsio/zcli/src/uxBlock/models/table" + "github.com/zeropsio/zerops-go/dto/input/body" + zeropsOutput "github.com/zeropsio/zerops-go/dto/output" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/enum" + "github.com/zeropsio/zerops-go/types/uuid" +) + +func processListCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("list"). + Short(i18n.T(i18n.CmdDescProcessList)). + HelpFlag(i18n.T(i18n.CmdHelpProcessList)). + StringFlag("status", "running", i18n.T(i18n.ProcessListFlagStatus)). + StringFlag("service-id", "", i18n.T(i18n.ProcessListFlagServiceId)). + IntFlag("limit", 100, i18n.T(i18n.ProcessListFlagLimit)). + ScopeLevel(cmdBuilder.ScopeProject()). + Arg(cmdBuilder.ProjectArgName, cmdBuilder.OptionalArg()). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + // Get project (required for clientId) + project, err := cmdData.Project.Expect("project is required") + if err != nil { + return err + } + + // Parse parameters + status := cmdData.Params.GetString("status") + serviceId := cmdData.Params.GetString("service-id") + limit := cmdData.Params.GetInt("limit") + outputFormat := cmdData.Params.GetString("output") + + // Validate limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + + // Validate status + validStatuses := map[string]bool{ + "running": true, "pending": true, "finished": true, "failed": true, "all": true, + } + if !validStatuses[status] { + return fmt.Errorf(i18n.T(i18n.ProcessListInvalidStatus), status) + } + + // Build ES filter + esFilter := body.EsFilter{ + Limit: types.NewIntNull(limit), + } + + // Add clientId filter (required by API) + esFilter.Search = append(esFilter.Search, body.EsSearchItem{ + Name: types.NewString("clientId"), + Operator: types.NewString("eq"), + Value: project.OrgId.TypedString(), + }) + + // Add project filter + esFilter.Search = append(esFilter.Search, body.EsSearchItem{ + Name: types.NewString("projectId"), + Operator: types.NewString("eq"), + Value: project.Id.TypedString(), + }) + + // Add service filter if provided + if serviceId != "" { + esFilter.Search = append(esFilter.Search, body.EsSearchItem{ + Name: types.NewString("serviceStackId"), + Operator: types.NewString("eq"), + Value: uuid.ServiceStackId(serviceId).TypedString(), + }) + } + + // Add status filter if not "all" + if status != "all" { + esFilter.Search = append(esFilter.Search, body.EsSearchItem{ + Name: types.NewString("status"), + Operator: types.NewString("eq"), + Value: types.String(statusToEnumNative(status)), + }) + } + + // Execute search + response, err := cmdData.RestApiClient.PostProcessSearch(ctx, esFilter) + if err != nil { + return errors.Wrap(err, "failed to search processes") + } + + searchOutput, err := response.Output() + if err != nil { + return errors.Wrap(err, "failed to parse response") + } + + processes := searchOutput.Items + + // Output based on format + if output.IsJSON(outputFormat) { + return outputProcessesJSON(cmdData, processes) + } + + return outputProcessesTable(cmdData, processes) + }) +} + +// statusToEnumNative converts status string to enum native value +func statusToEnumNative(status string) string { + switch status { + case "running": + return enum.ProcessStatusEnumRunning.Native() + case "pending": + return enum.ProcessStatusEnumPending.Native() + case "finished": + return enum.ProcessStatusEnumFinished.Native() + case "failed": + return enum.ProcessStatusEnumFailed.Native() + default: + return status + } +} + +// processJSON represents a process for JSON output +type processJSON struct { + ID string `json:"id"` + Status string `json:"status"` + ActionName string `json:"actionName"` + ProjectID string `json:"projectId"` + ServiceID *string `json:"serviceId,omitempty"` + Created string `json:"created"` + Finished *string `json:"finished,omitempty"` +} + +func outputProcessesJSON(cmdData *cmdBuilder.LoggedUserCmdData, processes []zeropsOutput.EsProcess) error { + result := make([]processJSON, 0, len(processes)) + + for _, p := range processes { + item := processJSON{ + ID: p.Id.TypedString().String(), + Status: p.Status.Native(), + ActionName: p.ActionName.Native(), + ProjectID: p.ProjectId.TypedString().String(), + Created: formatTime(p.Created.Native()), + } + + if serviceId, ok := p.ServiceStackId.Get(); ok { + sid := serviceId.TypedString().String() + item.ServiceID = &sid + } + + if finished, ok := p.Finished.Get(); ok { + f := formatTime(finished.Native()) + item.Finished = &f + } + + result = append(result, item) + } + + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} + +func outputProcessesTable(cmdData *cmdBuilder.LoggedUserCmdData, processes []zeropsOutput.EsProcess) error { + header := table.NewRowFromStrings("ID", "Status", "Action", "Project", "Service", "Created", "Finished") + tableBody := table.NewBody() + + for _, p := range processes { + serviceId := "-" + if sid, ok := p.ServiceStackId.Get(); ok { + sidStr := sid.TypedString().String() + if len(sidStr) > 8 { + serviceId = sidStr[:8] + "..." + } else { + serviceId = sidStr + } + } + + projectIdStr := p.ProjectId.TypedString().String() + projectId := projectIdStr + if len(projectIdStr) > 8 { + projectId = projectIdStr[:8] + "..." + } + + finished := "-" + if f, ok := p.Finished.Get(); ok { + finished = formatTimeShort(f.Native()) + } + + processIdStr := p.Id.TypedString().String() + processId := processIdStr + if len(processIdStr) > 8 { + processId = processIdStr[:8] + "..." + } + + tableBody.AddStringsRow( + processId, + p.Status.Native(), + p.ActionName.Native(), + projectId, + serviceId, + formatTimeShort(p.Created.Native()), + finished, + ) + } + + t := table.Render(tableBody, table.WithHeader(header)) + cmdData.Stdout.Println(t) + + return nil +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02T15:04:05Z") +} + +func formatTimeShort(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("01-02 15:04") +} diff --git a/src/cmd/projectEnv.go b/src/cmd/projectEnv.go index e381bb23..be68dd7a 100644 --- a/src/cmd/projectEnv.go +++ b/src/cmd/projectEnv.go @@ -21,6 +21,7 @@ func projectEnvCmd() *cmdBuilder.Cmd { ScopeLevel(cmdBuilder.ScopeProject()). Short("Print project envs to stdout."). HelpFlag("Help for the project env command."). + AddChildrenCmd(projectEnvSetCmd()). BoolFlag("export", false, "Prepends export keyword to each env in output: 'export {{.Key}}={{.Value}}'."). StringFlag("template", "{{.Key}}={{.Value}}", "Output template."). StringFlag("service", "", "Service name, in which context the environment variables are output."). diff --git a/src/cmd/projectEnvSet.go b/src/cmd/projectEnvSet.go new file mode 100644 index 00000000..f3719472 --- /dev/null +++ b/src/cmd/projectEnvSet.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/output" + "github.com/zeropsio/zcli/src/uxHelpers" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/types" +) + +// projectEnvSetResultJSON represents JSON output for project env set command +type projectEnvSetResultJSON struct { + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + Status string `json:"status"` + EnvVars map[string]string `json:"envVars"` +} + +// projectEnvSetCmd sets environment variables for the scoped project +func projectEnvSetCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("set"). + Short(i18n.T(i18n.CmdDescEnvSet)). + ScopeLevel(cmdBuilder.ScopeProject()). + Arg("KEY=VALUE", cmdBuilder.ArrayArg()). + HelpFlag(i18n.T(i18n.CmdHelpEnvSet)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + project, err := cmdData.Project.Expect("project is null") + if err != nil { + return err + } + + outputFormat := cmdData.Params.GetString("output") + + // Parse KEY=VALUE pairs from args + args, exists := cmdData.Args["KEY=VALUE"] + if !exists || len(args) == 0 { + return errors.New(i18n.T(i18n.EnvSetNoArgs)) + } + + envVars, err := ParseEnvVars(args) + if err != nil { + return err + } + + // Set environment variables + for _, envVar := range envVars { + envBody := body.ProjectEnvPost{ + ProjectId: project.Id, + Key: types.NewString(envVar.Key), + Content: types.NewText(envVar.Value), + Sensitive: types.NewBool(false), + } + + resp, err := cmdData.RestApiClient.PostProjectEnv(ctx, envBody) + if err != nil { + return errors.Wrap(err, i18n.T(i18n.EnvSetProjectFailed)) + } + + respOutput, err := resp.Output() + if err != nil { + return errors.Wrapf(err, "failed to parse response for '%s'", envVar.Key) + } + + // Track the process + err = uxHelpers.ProcessCheckWithSpinner( + ctx, + cmdData.UxBlocks, + []uxHelpers.Process{{ + F: uxHelpers.CheckZeropsProcess(respOutput.Id, cmdData.RestApiClient), + RunningMessage: fmt.Sprintf("Setting %s", envVar.Key), + ErrorMessageMessage: fmt.Sprintf("Failed to set %s", envVar.Key), + SuccessMessage: fmt.Sprintf("Set %s", envVar.Key), + }}, + ) + if err != nil { + return err + } + } + + // JSON output + if output.IsJSON(outputFormat) { + envVarsMap := make(map[string]string) + for _, envVar := range envVars { + envVarsMap[envVar.Key] = envVar.Value + } + result := projectEnvSetResultJSON{ + ProjectID: project.Id.TypedString().String(), + ProjectName: project.Name.String(), + Status: "success", + EnvVars: envVarsMap, + } + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + return nil + }) +} diff --git a/src/cmd/projectList.go b/src/cmd/projectList.go index 908b0bc6..f20854ba 100644 --- a/src/cmd/projectList.go +++ b/src/cmd/projectList.go @@ -2,9 +2,12 @@ package cmd import ( "context" + "encoding/json" "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/entity/repository" "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/output" "github.com/zeropsio/zcli/src/uxHelpers" ) @@ -14,11 +17,32 @@ func projectListCmd() *cmdBuilder.Cmd { Short(i18n.T(i18n.CmdDescProjectList)). HelpFlag(i18n.T(i18n.CmdHelpProjectList)). LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { - err := uxHelpers.PrintProjectList(ctx, cmdData.RestApiClient, cmdData.Stdout) - if err != nil { - return err + outputFormat := cmdData.Params.GetString("output") + + if output.IsJSON(outputFormat) { + projects, err := repository.GetAllProjects(ctx, cmdData.RestApiClient) + if err != nil { + return err + } + + // Convert to JSON-friendly format + result := make([]map[string]string, 0, len(projects)) + for _, p := range projects { + result = append(result, map[string]string{ + "id": string(p.Id), + "name": p.Name.String(), + "orgName": p.OrgName.Native(), + "orgId": p.OrgId.Native(), + "status": p.Status.String(), + "mode": p.Mode.String(), + }) + } + + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) } - return nil + return uxHelpers.PrintProjectList(ctx, cmdData.RestApiClient, cmdData.Stdout) }) } diff --git a/src/cmd/root.go b/src/cmd/root.go index 875e3f46..c07ffca3 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -23,6 +23,7 @@ func rootCmd() *cmdBuilder.Cmd { Use("zcli"). SetHelpTemplate(getRootTemplate()). SilenceError(true). + StringFlag("output", "table", i18n.T(i18n.OutputFormatFlag), cmdBuilder.PersistentFlag(), cmdBuilder.ShortHand("o")). AddChildrenCmd(loginCmd()). AddChildrenCmd(logoutCmd()). AddChildrenCmd(versionCmd()). @@ -34,6 +35,9 @@ func rootCmd() *cmdBuilder.Cmd { AddChildrenCmd(servicePushCmd()). AddChildrenCmd(envCmd()). AddChildrenCmd(supportCmd()). + AddChildrenCmd(processCmd()). + AddChildrenCmd(agentCmd()). + AddChildrenCmd(discoveryCmd()). GuestRunFunc(func(ctx context.Context, cmdData *cmdBuilder.GuestCmdData) error { cmdData.Stdout.PrintLines( i18n.T(i18n.GuestWelcome), @@ -57,7 +61,6 @@ func rootCmd() *cmdBuilder.Cmd { } } - // TODO: krls - check whole block if cmdData.CliStorage.Data().ScopeProjectId.Filled() { // project scope is set projectId, _ := cmdData.CliStorage.Data().ScopeProjectId.Get() diff --git a/src/cmd/service.go b/src/cmd/service.go index 6dee7ffa..1eb0f3fc 100644 --- a/src/cmd/service.go +++ b/src/cmd/service.go @@ -18,5 +18,8 @@ func serviceCmd() *cmdBuilder.Cmd { AddChildrenCmd(serviceStopCmd()). AddChildrenCmd(servicePushCmd()). AddChildrenCmd(serviceEnableSubdomainCmd()). - AddChildrenCmd(serviceDeployCmd()) + AddChildrenCmd(serviceDeployCmd()). + AddChildrenCmd(serviceScaleCmd()). + AddChildrenCmd(serviceTypesCmd()). + AddChildrenCmd(serviceEnvCmd()) } diff --git a/src/cmd/serviceEnv.go b/src/cmd/serviceEnv.go new file mode 100644 index 00000000..7ba01564 --- /dev/null +++ b/src/cmd/serviceEnv.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/output" + "github.com/zeropsio/zcli/src/uxHelpers" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/types" +) + +// serviceEnvCmd is the parent command for service env operations +func serviceEnvCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("env"). + Short(i18n.T(i18n.CmdDescServiceEnv)). + HelpFlag(i18n.T(i18n.CmdHelpServiceEnv)). + AddChildrenCmd(serviceEnvSetCmd()) +} + +// serviceEnvSetResultJSON represents JSON output for service env set command +type serviceEnvSetResultJSON struct { + ServiceID string `json:"serviceId"` + ServiceName string `json:"serviceName"` + Status string `json:"status"` + EnvVars map[string]string `json:"envVars"` +} + +// serviceEnvSetCmd sets environment variables for the scoped service +func serviceEnvSetCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("set"). + Short(i18n.T(i18n.CmdDescEnvSet)). + ScopeLevel(cmdBuilder.ScopeService()). + Arg("KEY=VALUE", cmdBuilder.ArrayArg()). + HelpFlag(i18n.T(i18n.CmdHelpEnvSet)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + service, err := cmdData.Service.Expect("service is null") + if err != nil { + return err + } + + outputFormat := cmdData.Params.GetString("output") + + // Parse KEY=VALUE pairs from args + args, exists := cmdData.Args["KEY=VALUE"] + if !exists || len(args) == 0 { + return errors.New(i18n.T(i18n.EnvSetNoArgs)) + } + + envVars, err := ParseEnvVars(args) + if err != nil { + return err + } + + // Set environment variables + for _, envVar := range envVars { + envBody := body.UserDataPost{ + ServiceStackId: service.Id, + Key: types.NewString(envVar.Key), + Content: types.NewText(envVar.Value), + } + + resp, err := cmdData.RestApiClient.PostUserData(ctx, envBody) + if err != nil { + return errors.Wrap(err, i18n.T(i18n.EnvSetServiceFailed)) + } + + respOutput, err := resp.Output() + if err != nil { + return errors.Wrapf(err, "failed to parse response for '%s'", envVar.Key) + } + + // Track the process + err = uxHelpers.ProcessCheckWithSpinner( + ctx, + cmdData.UxBlocks, + []uxHelpers.Process{{ + F: uxHelpers.CheckZeropsProcess(respOutput.Id, cmdData.RestApiClient), + RunningMessage: fmt.Sprintf("Setting %s", envVar.Key), + ErrorMessageMessage: fmt.Sprintf("Failed to set %s", envVar.Key), + SuccessMessage: fmt.Sprintf("Set %s", envVar.Key), + }}, + ) + if err != nil { + return err + } + } + + // JSON output + if output.IsJSON(outputFormat) { + envVarsMap := make(map[string]string) + for _, envVar := range envVars { + envVarsMap[envVar.Key] = envVar.Value + } + result := serviceEnvSetResultJSON{ + ServiceID: service.Id.TypedString().String(), + ServiceName: service.Name.String(), + Status: "success", + EnvVars: envVarsMap, + } + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + return nil + }) +} diff --git a/src/cmd/serviceList.go b/src/cmd/serviceList.go index 9a6b252e..27ba61e3 100644 --- a/src/cmd/serviceList.go +++ b/src/cmd/serviceList.go @@ -2,9 +2,12 @@ package cmd import ( "context" + "encoding/json" "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/entity/repository" "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/output" "github.com/zeropsio/zcli/src/uxHelpers" ) @@ -20,14 +23,35 @@ func serviceListCmd() *cmdBuilder.Cmd { if err != nil { return err } - if err := uxHelpers.PrintServiceList( + + outputFormat := cmdData.Params.GetString("output") + + if output.IsJSON(outputFormat) { + services, err := repository.GetNonSystemServicesByProject(ctx, cmdData.RestApiClient, project) + if err != nil { + return err + } + + // Convert to JSON-friendly format + result := make([]map[string]string, 0, len(services)) + for _, s := range services { + result = append(result, map[string]string{ + "id": string(s.Id), + "name": s.Name.String(), + "status": s.Status.String(), + }) + } + + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + return uxHelpers.PrintServiceList( ctx, cmdData.RestApiClient, cmdData.Stdout, project, - ); err != nil { - return err - } - return nil + ) }) } diff --git a/src/cmd/serviceScale.go b/src/cmd/serviceScale.go new file mode 100644 index 00000000..405cba71 --- /dev/null +++ b/src/cmd/serviceScale.go @@ -0,0 +1,788 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + zcliOutput "github.com/zeropsio/zcli/src/output" + "github.com/zeropsio/zcli/src/terminal" + "github.com/zeropsio/zcli/src/uxBlock" + "github.com/zeropsio/zcli/src/uxBlock/models/input" + "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/dto/output" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/enum" +) + +func serviceScaleCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("scale"). + Short(i18n.T(i18n.CmdDescServiceScale)). + ScopeLevel(cmdBuilder.ScopeService()). + Arg(cmdBuilder.ServiceArgName, cmdBuilder.OptionalArg(), cmdBuilder.OptionalArgLabel("{service-name | service-id}")). + HelpFlag(i18n.T(i18n.CmdHelpServiceScale)). + IntFlag("cpu-min", 0, i18n.T(i18n.ServiceScaleFlagCpuMin)). + IntFlag("cpu-max", 0, i18n.T(i18n.ServiceScaleFlagCpuMax)). + StringFlag("ram-min", "", i18n.T(i18n.ServiceScaleFlagRamMin)). + StringFlag("ram-max", "", i18n.T(i18n.ServiceScaleFlagRamMax)). + StringFlag("disk-min", "", i18n.T(i18n.ServiceScaleFlagDiskMin)). + StringFlag("disk-max", "", i18n.T(i18n.ServiceScaleFlagDiskMax)). + IntFlag("replicas-min", 0, i18n.T(i18n.ServiceScaleFlagReplicasMin)). + IntFlag("replicas-max", 0, i18n.T(i18n.ServiceScaleFlagReplicasMax)). + StringFlag("cpu-mode", "", i18n.T(i18n.ServiceScaleFlagCpuMode)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + service, err := cmdData.Service.Expect("service is null") + if err != nil { + return err + } + + // Get current service configuration + getServiceResp, err := cmdData.RestApiClient.GetServiceStack(ctx, path.ServiceStackId{Id: service.Id}) + if err != nil { + return errors.Wrap(err, "failed to get service configuration") + } + + serviceDetail, err := getServiceResp.Output() + if err != nil { + return errors.Wrap(err, "failed to parse service configuration") + } + + // Parse input from flags + scaleInput, err := parseScaleInput(cmdData) + if err != nil { + return err + } + + // If no parameters provided and terminal is interactive, ask for them + if !hasAnyScalingParams(scaleInput) { + if terminal.IsTerminal() { + scaleInput, err = interactiveScaleInput(ctx, cmdData, serviceDetail) + if err != nil { + return err + } + } else { + return errors.New(i18n.T(i18n.ServiceScaleNoParams)) + } + } + + // Validate input + if err := validateScaleInput(scaleInput, serviceDetail); err != nil { + return err + } + + // Build autoscaling configuration + autoscalingBody, err := buildAutoscalingConfig(scaleInput, serviceDetail) + if err != nil { + return err + } + + // Apply scaling + response, err := cmdData.RestApiClient.PutServiceStackAutoscaling( + ctx, + path.ServiceStackId{Id: service.Id}, + *autoscalingBody, + ) + if err != nil { + return handleScaleApiError(err, service.Name.String()) + } + + responseOutput, err := response.Output() + if err != nil { + return err + } + + outputFormat := cmdData.Params.GetString("output") + + // Track the process if one was returned + if responseOutput.Process != nil { + processId := responseOutput.Process.Id + err = uxHelpers.ProcessCheckWithSpinner( + ctx, + cmdData.UxBlocks, + []uxHelpers.Process{{ + F: uxHelpers.CheckZeropsProcess(processId, cmdData.RestApiClient), + RunningMessage: i18n.T(i18n.ServiceScaling), + ErrorMessageMessage: i18n.T(i18n.ServiceScaleFailed), + SuccessMessage: i18n.T(i18n.ServiceScaled), + }}, + ) + if err != nil { + return err + } + } else if !zcliOutput.IsJSON(outputFormat) { + cmdData.UxBlocks.PrintSuccessText(i18n.T(i18n.ServiceScaled)) + } + + // JSON output + if zcliOutput.IsJSON(outputFormat) { + result := scaleResultJSON{ + ServiceID: service.Id.TypedString().String(), + ServiceName: service.Name.String(), + Status: "success", + Message: i18n.T(i18n.ServiceScaled), + } + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + return nil + }) +} + +// scaleResultJSON represents the JSON output for scale command +type scaleResultJSON struct { + ServiceID string `json:"serviceId"` + ServiceName string `json:"serviceName"` + Status string `json:"status"` + Message string `json:"message"` +} + +// interactiveScaleInput prompts the user for scaling parameters +func interactiveScaleInput(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData, service output.ServiceStack) (*scaleInput, error) { + result := &scaleInput{} + + // Get current values + currentCpuMin, currentCpuMax := getCurrentCPU(service) + currentRamMin, currentRamMax := getCurrentRAM(service) + currentDiskMin, currentDiskMax := getCurrentDisk(service) + currentCpuMode := getCurrentCPUMode(service) + currentReplicasMin, currentReplicasMax := getCurrentReplicas(service) + + cmdData.Stdout.Println("") + cmdData.Stdout.Println(i18n.T(i18n.ServiceScaleCurrentConfig)) + cmdData.Stdout.Println(fmt.Sprintf(" CPU: %d - %d cores (%s)", currentCpuMin, currentCpuMax, currentCpuMode)) + cmdData.Stdout.Println(fmt.Sprintf(" RAM: %.2f - %.2f GB", currentRamMin, currentRamMax)) + cmdData.Stdout.Println(fmt.Sprintf(" Disk: %.0f - %.0f GB", currentDiskMin, currentDiskMax)) + cmdData.Stdout.Println(fmt.Sprintf(" Replicas: %d - %d containers", currentReplicasMin, currentReplicasMax)) + cmdData.Stdout.Println("") + cmdData.Stdout.Println(i18n.T(i18n.ServiceScaleEnterNewValues)) + cmdData.Stdout.Println("") + + // CPU Min + cpuMinStr, err := promptInput(ctx, fmt.Sprintf("CPU min [%d]", currentCpuMin), "") + if err != nil { + return nil, err + } + if cpuMinStr != "" { + v, err := strconv.Atoi(cpuMinStr) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "CPU min")) + } + result.cpuMin = &v + } + + // CPU Max + cpuMaxStr, err := promptInput(ctx, fmt.Sprintf("CPU max [%d]", currentCpuMax), "") + if err != nil { + return nil, err + } + if cpuMaxStr != "" { + v, err := strconv.Atoi(cpuMaxStr) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "CPU max")) + } + result.cpuMax = &v + } + + // RAM Min + ramMinStr, err := promptInput(ctx, fmt.Sprintf("RAM min GB [%.2f]", currentRamMin), "") + if err != nil { + return nil, err + } + if ramMinStr != "" { + v, err := strconv.ParseFloat(ramMinStr, 64) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "RAM min")) + } + result.ramMin = &v + } + + // RAM Max + ramMaxStr, err := promptInput(ctx, fmt.Sprintf("RAM max GB [%.2f]", currentRamMax), "") + if err != nil { + return nil, err + } + if ramMaxStr != "" { + v, err := strconv.ParseFloat(ramMaxStr, 64) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "RAM max")) + } + result.ramMax = &v + } + + // Disk Min + diskMinStr, err := promptInput(ctx, fmt.Sprintf("Disk min GB [%.0f]", currentDiskMin), "") + if err != nil { + return nil, err + } + if diskMinStr != "" { + v, err := strconv.ParseFloat(diskMinStr, 64) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "Disk min")) + } + result.diskMin = &v + } + + // Disk Max + diskMaxStr, err := promptInput(ctx, fmt.Sprintf("Disk max GB [%.0f]", currentDiskMax), "") + if err != nil { + return nil, err + } + if diskMaxStr != "" { + v, err := strconv.ParseFloat(diskMaxStr, 64) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "Disk max")) + } + result.diskMax = &v + } + + // CPU Mode + cpuModeStr, err := promptInput(ctx, fmt.Sprintf("CPU mode (SHARED/DEDICATED) [%s]", currentCpuMode), "") + if err != nil { + return nil, err + } + if cpuModeStr != "" { + upper := strings.ToUpper(cpuModeStr) + if upper != "SHARED" && upper != "DEDICATED" { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "CPU mode")) + } + result.cpuMode = &upper + } + + // Replicas Min + replicasMinStr, err := promptInput(ctx, fmt.Sprintf("Replicas min [%d]", currentReplicasMin), "") + if err != nil { + return nil, err + } + if replicasMinStr != "" { + v, err := strconv.Atoi(replicasMinStr) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "Replicas min")) + } + result.replicasMin = &v + } + + // Replicas Max + replicasMaxStr, err := promptInput(ctx, fmt.Sprintf("Replicas max [%d]", currentReplicasMax), "") + if err != nil { + return nil, err + } + if replicasMaxStr != "" { + v, err := strconv.Atoi(replicasMaxStr) + if err != nil { + return nil, errors.New(i18n.T(i18n.ServiceScaleInvalidValue, "Replicas max")) + } + result.replicasMax = &v + } + + // Check if at least one value was changed + if !hasAnyScalingParams(result) { + return nil, errors.New(i18n.T(i18n.ServiceScaleNoChanges)) + } + + return result, nil +} + +// promptInput shows a prompt and returns user input +func promptInput(ctx context.Context, label string, defaultValue string) (string, error) { + value, err := uxBlock.Run( + input.NewRoot( + ctx, + input.WithLabel(label), + input.WithoutPrompt(), + ), + input.GetValueFunc, + ) + if err != nil { + return "", err + } + if value == "" { + return defaultValue, nil + } + return value, nil +} + +// getCurrentCPU returns current CPU min/max values +func getCurrentCPU(service output.ServiceStack) (int, int) { + minCpu, maxCpu := 1, 1 + if service.CustomAutoscaling != nil && service.CustomAutoscaling.VerticalAutoscalingNullable != nil { + v := service.CustomAutoscaling.VerticalAutoscalingNullable + if v.MinResource != nil { + if cpu, ok := v.MinResource.CpuCoreCount.Get(); ok { + minCpu = int(cpu) + } + } + if v.MaxResource != nil { + if cpu, ok := v.MaxResource.CpuCoreCount.Get(); ok { + maxCpu = int(cpu) + } + } + } + return minCpu, maxCpu +} + +// getCurrentRAM returns current RAM min/max values +func getCurrentRAM(service output.ServiceStack) (float64, float64) { + minRam, maxRam := 0.25, 0.25 + if service.CustomAutoscaling != nil && service.CustomAutoscaling.VerticalAutoscalingNullable != nil { + v := service.CustomAutoscaling.VerticalAutoscalingNullable + if v.MinResource != nil { + if ram, ok := v.MinResource.MemoryGBytes.Get(); ok { + minRam = float64(ram) + } + } + if v.MaxResource != nil { + if ram, ok := v.MaxResource.MemoryGBytes.Get(); ok { + maxRam = float64(ram) + } + } + } + return minRam, maxRam +} + +// getCurrentDisk returns current Disk min/max values +func getCurrentDisk(service output.ServiceStack) (float64, float64) { + minDisk, maxDisk := 1.0, 1.0 + if service.CustomAutoscaling != nil && service.CustomAutoscaling.VerticalAutoscalingNullable != nil { + v := service.CustomAutoscaling.VerticalAutoscalingNullable + if v.MinResource != nil { + if disk, ok := v.MinResource.DiskGBytes.Get(); ok { + minDisk = float64(disk) + } + } + if v.MaxResource != nil { + if disk, ok := v.MaxResource.DiskGBytes.Get(); ok { + maxDisk = float64(disk) + } + } + } + return minDisk, maxDisk +} + +// getCurrentCPUMode returns current CPU mode +func getCurrentCPUMode(service output.ServiceStack) string { + if service.CustomAutoscaling != nil && service.CustomAutoscaling.VerticalAutoscalingNullable != nil { + if service.CustomAutoscaling.VerticalAutoscalingNullable.CpuMode != nil { + return service.CustomAutoscaling.VerticalAutoscalingNullable.CpuMode.String() + } + } + return "SHARED" +} + +// getCurrentReplicas returns current replicas min/max values +func getCurrentReplicas(service output.ServiceStack) (int, int) { + minReplicas, maxReplicas := 1, 1 + if service.CustomAutoscaling != nil && service.CustomAutoscaling.HorizontalAutoscalingNullable != nil { + h := service.CustomAutoscaling.HorizontalAutoscalingNullable + if min, ok := h.MinContainerCount.Get(); ok { + minReplicas = int(min) + } + if max, ok := h.MaxContainerCount.Get(); ok { + maxReplicas = int(max) + } + } + return minReplicas, maxReplicas +} + +// scaleInput holds parsed scaling parameters +type scaleInput struct { + cpuMin *int + cpuMax *int + ramMin *float64 + ramMax *float64 + diskMin *float64 + diskMax *float64 + replicasMin *int + replicasMax *int + cpuMode *string +} + +// parseScaleInput extracts scaling parameters from command flags +func parseScaleInput(cmdData *cmdBuilder.LoggedUserCmdData) (*scaleInput, error) { + result := &scaleInput{} + + // Parse CPU + if v := cmdData.Params.GetInt("cpu-min"); v > 0 { + result.cpuMin = &v + } + if v := cmdData.Params.GetInt("cpu-max"); v > 0 { + result.cpuMax = &v + } + + // Parse RAM + if v := cmdData.Params.GetString("ram-min"); v != "" { + ram, err := parseRAM(v) + if err != nil { + return nil, errors.Wrap(err, "invalid ram-min") + } + result.ramMin = &ram + } + if v := cmdData.Params.GetString("ram-max"); v != "" { + ram, err := parseRAM(v) + if err != nil { + return nil, errors.Wrap(err, "invalid ram-max") + } + result.ramMax = &ram + } + + // Parse Disk + if v := cmdData.Params.GetString("disk-min"); v != "" { + disk, err := parseDisk(v) + if err != nil { + return nil, errors.Wrap(err, "invalid disk-min") + } + result.diskMin = &disk + } + if v := cmdData.Params.GetString("disk-max"); v != "" { + disk, err := parseDisk(v) + if err != nil { + return nil, errors.Wrap(err, "invalid disk-max") + } + result.diskMax = &disk + } + + // Parse Replicas + if v := cmdData.Params.GetInt("replicas-min"); v > 0 { + result.replicasMin = &v + } + if v := cmdData.Params.GetInt("replicas-max"); v > 0 { + result.replicasMax = &v + } + + // Parse CPU Mode + if v := cmdData.Params.GetString("cpu-mode"); v != "" { + result.cpuMode = &v + } + + return result, nil +} + +// parseRAM parses RAM string value to float64 GB +func parseRAM(s string) (float64, error) { + var value float64 + _, err := fmt.Sscanf(s, "%f", &value) + if err != nil { + return 0, errors.New("invalid RAM format, use number in GB (e.g., 0.5, 1, 2.5)") + } + return value, nil +} + +// parseDisk parses disk string value to float64 GB +func parseDisk(s string) (float64, error) { + var value float64 + _, err := fmt.Sscanf(s, "%f", &value) + if err != nil { + return 0, errors.New("invalid disk format, use number in GB (e.g., 5, 10, 50)") + } + return value, nil +} + +// validateScaleInput validates the scaling input +func validateScaleInput(input *scaleInput, service output.ServiceStack) error { + // Validate CPU mode if provided + if input.cpuMode != nil { + if *input.cpuMode != "SHARED" && *input.cpuMode != "DEDICATED" { + return errors.New("cpu-mode must be SHARED or DEDICATED") + } + } + + // Validate CPU range (1-8) + if err := validateResourceRange("CPU", input.cpuMin, input.cpuMax, 1, 8); err != nil { + return err + } + + // Validate RAM range (0.125-48) and increments + if err := validateResourceRangeFloat("RAM", input.ramMin, input.ramMax, 0.125, 48); err != nil { + return err + } + if input.ramMin != nil && !isValidRAMIncrement(*input.ramMin) { + return errors.Errorf("ram-min must be a multiple of 0.125 GB (got %.3f)", *input.ramMin) + } + if input.ramMax != nil && !isValidRAMIncrement(*input.ramMax) { + return errors.Errorf("ram-max must be a multiple of 0.125 GB (got %.3f)", *input.ramMax) + } + + // Validate Disk range (1-250) + if err := validateResourceRangeFloat("Disk", input.diskMin, input.diskMax, 1, 250); err != nil { + return err + } + + // Validate Replicas range (1-10) + if err := validateResourceRange("Replicas", input.replicasMin, input.replicasMax, 1, 10); err != nil { + return err + } + + // Check if horizontal scaling is supported + if input.replicasMin != nil || input.replicasMax != nil { + if service.Mode.String() == "NON_HA" { + return errors.New("horizontal scaling (replicas) is not supported in NON_HA mode") + } + } + + return nil +} + +// validateResourceRange validates integer min/max values +func validateResourceRange(name string, minVal, maxVal *int, minLimit, maxLimit int) error { + if minVal != nil && (*minVal < minLimit || *minVal > maxLimit) { + return errors.Errorf("%s min must be between %d and %d (got %d)", name, minLimit, maxLimit, *minVal) + } + if maxVal != nil && (*maxVal < minLimit || *maxVal > maxLimit) { + return errors.Errorf("%s max must be between %d and %d (got %d)", name, minLimit, maxLimit, *maxVal) + } + if minVal != nil && maxVal != nil && *minVal > *maxVal { + return errors.Errorf("%s min (%d) cannot be greater than max (%d)", name, *minVal, *maxVal) + } + return nil +} + +// validateResourceRangeFloat validates float min/max values +func validateResourceRangeFloat(name string, minVal, maxVal *float64, minLimit, maxLimit float64) error { + if minVal != nil && (*minVal < minLimit || *minVal > maxLimit) { + return errors.Errorf("%s min must be between %.3f and %.0f (got %.3f)", name, minLimit, maxLimit, *minVal) + } + if maxVal != nil && (*maxVal < minLimit || *maxVal > maxLimit) { + return errors.Errorf("%s max must be between %.3f and %.0f (got %.3f)", name, minLimit, maxLimit, *maxVal) + } + if minVal != nil && maxVal != nil && *minVal > *maxVal { + return errors.Errorf("%s min (%.3f) cannot be greater than max (%.3f)", name, *minVal, *maxVal) + } + return nil +} + +// isValidRAMIncrement checks if RAM value is a valid 0.125 increment +func isValidRAMIncrement(value float64) bool { + valueInMB := int(math.Round(value * 1000)) + return valueInMB%125 == 0 +} + +// hasAnyScalingParams checks if any scaling parameters are provided +func hasAnyScalingParams(input *scaleInput) bool { + return input.cpuMin != nil || input.cpuMax != nil || + input.ramMin != nil || input.ramMax != nil || + input.diskMin != nil || input.diskMax != nil || + input.replicasMin != nil || input.replicasMax != nil || + input.cpuMode != nil +} + +// buildAutoscalingConfig builds the autoscaling configuration body +func buildAutoscalingConfig(input *scaleInput, service output.ServiceStack) (*body.Autoscaling, error) { + mode := service.Mode + config := body.Autoscaling{ + Mode: &mode, + } + + customConfig := &body.CustomAutoscaling{} + + vertical := buildVerticalConfig(input, service) + if vertical != nil { + customConfig.VerticalAutoscaling = vertical + } + + horizontal := buildHorizontalConfig(input, service) + if horizontal != nil { + customConfig.HorizontalAutoscaling = horizontal + } + + config.CustomAutoscaling = customConfig + return &config, nil +} + +// buildVerticalConfig builds vertical autoscaling configuration +func buildVerticalConfig(input *scaleInput, service output.ServiceStack) *body.VerticalAutoscalingNullable { + hasVertical := input.cpuMin != nil || input.cpuMax != nil || + input.ramMin != nil || input.ramMax != nil || + input.diskMin != nil || input.diskMax != nil || + input.cpuMode != nil + + if !hasVertical { + return copyExistingVerticalConfig(service) + } + + config := &body.VerticalAutoscalingNullable{} + existing := getExistingVerticalConfig(service) + + if input.cpuMode != nil { + cpuModeEnum := enum.VerticalAutoscalingCpuModeEnum(*input.cpuMode) + config.CpuMode = &cpuModeEnum + } else if existing != nil && existing.CpuMode != nil { + config.CpuMode = existing.CpuMode + } + + config.MinResource = buildResourceConfig(input.cpuMin, input.ramMin, input.diskMin, getMinResource(existing)) + config.MaxResource = buildResourceConfig(input.cpuMax, input.ramMax, input.diskMax, getMaxResource(existing)) + + if existing != nil { + config.SwapEnabled = existing.SwapEnabled + config.StartCpuCoreCount = existing.StartCpuCoreCount + } + + return config +} + +// buildResourceConfig builds a scaling resource configuration +func buildResourceConfig(cpu *int, ram *float64, disk *float64, existing *output.ScalingResourceNullable) *body.ScalingResourceNullable { + resource := &body.ScalingResourceNullable{} + hasValues := false + + if cpu != nil { + resource.CpuCoreCount = types.NewIntNull(*cpu) + hasValues = true + } else if existing != nil { + if cpuVal, ok := existing.CpuCoreCount.Get(); ok { + resource.CpuCoreCount = types.NewIntNull(int(cpuVal)) + hasValues = true + } + } + + if ram != nil { + resource.MemoryGBytes = types.NewFloatNull(*ram) + hasValues = true + } else if existing != nil { + if ramVal, ok := existing.MemoryGBytes.Get(); ok { + resource.MemoryGBytes = types.NewFloatNull(float64(ramVal)) + hasValues = true + } + } + + if disk != nil { + resource.DiskGBytes = types.NewFloatNull(*disk) + hasValues = true + } else if existing != nil { + if diskVal, ok := existing.DiskGBytes.Get(); ok { + resource.DiskGBytes = types.NewFloatNull(float64(diskVal)) + hasValues = true + } + } + + if hasValues { + return resource + } + return nil +} + +// buildHorizontalConfig builds horizontal autoscaling configuration +func buildHorizontalConfig(input *scaleInput, service output.ServiceStack) *body.HorizontalAutoscalingNullable { + hasHorizontal := input.replicasMin != nil || input.replicasMax != nil + + if !hasHorizontal { + return copyExistingHorizontalConfig(service) + } + + config := &body.HorizontalAutoscalingNullable{} + existing := getExistingHorizontalConfig(service) + + if input.replicasMin != nil { + config.MinContainerCount = types.NewIntNull(*input.replicasMin) + } else if existing != nil { + if min, ok := existing.MinContainerCount.Get(); ok { + config.MinContainerCount = types.NewIntNull(int(min)) + } + } + + if input.replicasMax != nil { + config.MaxContainerCount = types.NewIntNull(*input.replicasMax) + } else if existing != nil { + if max, ok := existing.MaxContainerCount.Get(); ok { + config.MaxContainerCount = types.NewIntNull(int(max)) + } + } + + return config +} + +func getExistingVerticalConfig(service output.ServiceStack) *output.VerticalAutoscalingNullable { + if service.CustomAutoscaling == nil || service.CustomAutoscaling.VerticalAutoscalingNullable == nil { + return nil + } + return service.CustomAutoscaling.VerticalAutoscalingNullable +} + +func getMinResource(config *output.VerticalAutoscalingNullable) *output.ScalingResourceNullable { + if config == nil || config.MinResource == nil { + return nil + } + return config.MinResource +} + +func getMaxResource(config *output.VerticalAutoscalingNullable) *output.ScalingResourceNullable { + if config == nil || config.MaxResource == nil { + return nil + } + return config.MaxResource +} + +func getExistingHorizontalConfig(service output.ServiceStack) *output.HorizontalAutoscalingNullable { + if service.CustomAutoscaling == nil || service.CustomAutoscaling.HorizontalAutoscalingNullable == nil { + return nil + } + return service.CustomAutoscaling.HorizontalAutoscalingNullable +} + +func copyExistingVerticalConfig(service output.ServiceStack) *body.VerticalAutoscalingNullable { + existing := getExistingVerticalConfig(service) + if existing == nil { + return nil + } + + result := &body.VerticalAutoscalingNullable{ + CpuMode: existing.CpuMode, + StartCpuCoreCount: existing.StartCpuCoreCount, + SwapEnabled: existing.SwapEnabled, + } + + if existing.MinResource != nil { + result.MinResource = buildResourceConfig(nil, nil, nil, existing.MinResource) + } + if existing.MaxResource != nil { + result.MaxResource = buildResourceConfig(nil, nil, nil, existing.MaxResource) + } + + return result +} + +func copyExistingHorizontalConfig(service output.ServiceStack) *body.HorizontalAutoscalingNullable { + existing := getExistingHorizontalConfig(service) + if existing == nil { + return nil + } + + result := &body.HorizontalAutoscalingNullable{} + + if min, ok := existing.MinContainerCount.Get(); ok { + result.MinContainerCount = types.NewIntNull(int(min)) + } + if max, ok := existing.MaxContainerCount.Get(); ok { + result.MaxContainerCount = types.NewIntNull(int(max)) + } + + return result +} + +func handleScaleApiError(err error, serviceName string) error { + errStr := err.Error() + + if strings.Contains(errStr, "lastCpuModeChangeTooRecent") { + return errors.Errorf("cpu mode change failed for service '%s': cpu mode was changed too recently", serviceName) + } + + if strings.Contains(errStr, "customVerticalAutoscalingForbiddenForThisStackCategory") { + return errors.Errorf("scaling failed for service '%s': this service type does not support vertical autoscaling", serviceName) + } + + if strings.Contains(errStr, "invalidCustomAutoscalingValue") { + return errors.Errorf("scaling failed for service '%s': invalid autoscaling values", serviceName) + } + + return errors.Wrapf(err, "scaling operation failed for service '%s'", serviceName) +} diff --git a/src/cmd/serviceTypes.go b/src/cmd/serviceTypes.go new file mode 100644 index 00000000..88f3ebe6 --- /dev/null +++ b/src/cmd/serviceTypes.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "context" + "encoding/json" + "sort" + "strings" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/output" + "github.com/zeropsio/zcli/src/uxBlock/models/table" + "github.com/zeropsio/zerops-go/dto/input/body" + zeropsOutput "github.com/zeropsio/zerops-go/dto/output" + "github.com/zeropsio/zerops-go/types" +) + +func serviceTypesCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("types"). + Short(i18n.T(i18n.CmdDescServiceTypes)). + HelpFlag(i18n.T(i18n.CmdHelpServiceTypes)). + StringFlag("category", "", i18n.T(i18n.ServiceTypesFlagCategory)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + category := cmdData.Params.GetString("category") + outputFormat := cmdData.Params.GetString("output") + + // Build ES filter + esFilter := body.EsFilter{ + Limit: types.NewIntNull(500), + } + + // Add category filter if provided + if category != "" { + esFilter.Search = append(esFilter.Search, body.EsSearchItem{ + Name: types.NewString("category"), + Operator: types.NewString("eq"), + Value: types.NewString(category), + }) + } + + // Execute search + response, err := cmdData.RestApiClient.PostServiceStackTypeSearch(ctx, esFilter) + if err != nil { + return errors.Wrap(err, "failed to search service types") + } + + searchOutput, err := response.Output() + if err != nil { + return errors.Wrap(err, "failed to parse response") + } + + // Group by category and service name + serviceTypes := groupServiceTypesFromOutput(searchOutput.Items) + + // Output based on format + if output.IsJSON(outputFormat) { + return outputServiceTypesJSON(cmdData, serviceTypes) + } + + return outputServiceTypesTable(cmdData, serviceTypes) + }) +} + +// serviceTypeGroup represents a grouped service type +type serviceTypeGroup struct { + Category string `json:"category"` + Service string `json:"service"` + Versions []string `json:"versions"` +} + +// groupServiceTypesFromOutput groups service types by category and service name +func groupServiceTypesFromOutput(items []zeropsOutput.EsServiceStackType) []serviceTypeGroup { + // Map to group versions by category+service + groups := make(map[string]*serviceTypeGroup) + + for _, item := range items { + category := item.Category.String() + name := item.Name.String() + + // Collect versions from ServiceStackTypeVersionList + versions := []string{} + for _, v := range item.ServiceStackTypeVersionList { + if versionName := v.Name.String(); versionName != "" { + versions = append(versions, versionName) + } + } + + key := category + "|" + name + if _, exists := groups[key]; !exists { + groups[key] = &serviceTypeGroup{ + Category: category, + Service: name, + Versions: versions, + } + } else { + // Append versions if the group already exists + groups[key].Versions = append(groups[key].Versions, versions...) + } + } + + // Convert map to slice and sort + result := make([]serviceTypeGroup, 0, len(groups)) + for _, g := range groups { + // Deduplicate and sort versions + g.Versions = uniqueStrings(g.Versions) + sort.Strings(g.Versions) + result = append(result, *g) + } + + // Sort by category then by service + sort.Slice(result, func(i, j int) bool { + if result[i].Category != result[j].Category { + return result[i].Category < result[j].Category + } + return result[i].Service < result[j].Service + }) + + return result +} + +// uniqueStrings removes duplicates from a string slice +func uniqueStrings(input []string) []string { + seen := make(map[string]bool) + result := []string{} + for _, s := range input { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} + +func outputServiceTypesJSON(cmdData *cmdBuilder.LoggedUserCmdData, serviceTypes []serviceTypeGroup) error { + encoder := json.NewEncoder(cmdData.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(serviceTypes) +} + +func outputServiceTypesTable(cmdData *cmdBuilder.LoggedUserCmdData, serviceTypes []serviceTypeGroup) error { + header := table.NewRowFromStrings("Category", "Service", "Versions") + tableBody := table.NewBody() + + for _, t := range serviceTypes { + versions := strings.Join(t.Versions, ", ") + if len(versions) > 50 { + versions = versions[:47] + "..." + } + tableBody.AddStringsRow(t.Category, t.Service, versions) + } + + t := table.Render(tableBody, table.WithHeader(header)) + cmdData.Stdout.Println(t) + + return nil +} diff --git a/src/cmdBuilder/buildCobraCmd.go b/src/cmdBuilder/buildCobraCmd.go index bfb75338..93e6a584 100644 --- a/src/cmdBuilder/buildCobraCmd.go +++ b/src/cmdBuilder/buildCobraCmd.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/zeropsio/zcli/src/cliStorage" "github.com/zeropsio/zcli/src/flagParams" "github.com/zeropsio/zcli/src/uxBlock" @@ -44,7 +45,12 @@ func buildCobraCmd( } for _, flag := range cmd.flags { - flagSet := cobraCmd.Flags() + var flagSet *pflag.FlagSet + if flag.persistent { + flagSet = cobraCmd.PersistentFlags() + } else { + flagSet = cobraCmd.Flags() + } switch defaultValue := flag.defaultValue.(type) { case string: flagSet.StringP(flag.name, flag.shorthand, defaultValue, flag.description) diff --git a/src/cmdBuilder/cmd.go b/src/cmdBuilder/cmd.go index 7619ff6d..4d4d2a4b 100644 --- a/src/cmdBuilder/cmd.go +++ b/src/cmdBuilder/cmd.go @@ -47,6 +47,7 @@ type cmdFlag struct { description string hidden bool shorthand string + persistent bool } func NewCmd() *Cmd { @@ -145,6 +146,12 @@ func HiddenFlag() FlagOption { } } +func PersistentFlag() FlagOption { + return func(cfg *cmdFlag) { + cfg.persistent = true + } +} + func ShortHand(shorthand string) FlagOption { return func(cfg *cmdFlag) { cfg.shorthand = shorthand diff --git a/src/i18n/en.go b/src/i18n/en.go index 2fcdbd06..b2330c0b 100644 --- a/src/i18n/en.go +++ b/src/i18n/en.go @@ -346,4 +346,100 @@ more info: https://docs.zerops.io/references/cli/`, ErrorInvalidScopedProjectId: "Invalid ID of the scoped project [%s], select a different project using `zcli scope project` command.", ErrorInvalidServiceId: "Invalid service ID [%s], %s", // values: serviceId, message ErrorServiceNotFound: "Service [%s] not found", + + // output format + OutputFormatFlag: "Output format: table or json.", + + // service scale + CmdDescServiceScale: "Scales the Zerops service resources.", + CmdHelpServiceScale: "Help for the service scale command.", + ServiceScaling: "Service is being scaled", + ServiceScaleFailed: "Service scaling failed", + ServiceScaled: "Service was scaled", + + // process + CmdDescProcess: "Process commands group", + CmdHelpProcess: "Help for the process command.", + CmdDescProcessList: "Lists infrastructure processes.", + CmdHelpProcessList: "Help for the process list command.", + + // env set + CmdDescEnvSet: "Sets environment variables.", + CmdHelpEnvSet: "Help for the env set command.", + + // service types + CmdDescServiceTypes: "Lists available service types.", + CmdHelpServiceTypes: "Help for the service types command.", + + // agent + CmdDescAgent: "Agent commands group", + CmdHelpAgent: "Help for the agent command.", + CmdDescAgentInit: "Initializes the AI agent configuration file.", + CmdHelpAgentInit: "Help for the agent init command.", + + // service scale flags + ServiceScaleFlagCpuMin: "Minimum CPU cores (1-8). 0 = keep current value.", + ServiceScaleFlagCpuMax: "Maximum CPU cores (1-8). 0 = keep current value.", + ServiceScaleFlagRamMin: "Minimum RAM in GB (0.125-48, multiples of 0.125).", + ServiceScaleFlagRamMax: "Maximum RAM in GB (0.125-48, multiples of 0.125).", + ServiceScaleFlagDiskMin: "Minimum disk in GB (1-250).", + ServiceScaleFlagDiskMax: "Maximum disk in GB (1-250).", + ServiceScaleFlagReplicasMin: "Minimum containers (1-10). 0 = keep current value.", + ServiceScaleFlagReplicasMax: "Maximum containers (1-10). 0 = keep current value.", + ServiceScaleFlagCpuMode: "CPU mode: SHARED or DEDICATED.", + ServiceScaleNoParams: "At least one scaling parameter must be specified, or use interactive mode.", + ServiceScaleInvalidCpu: "CPU must be between 1 and 8 cores", + ServiceScaleInvalidRam: "RAM must be between 0.125 and 48 GB in multiples of 0.125", + ServiceScaleInvalidDisk: "Disk must be between 1 and 250 GB", + ServiceScaleInvalidReplicas: "Replicas must be between 1 and 10", + ServiceScaleMinMaxError: "Minimum value cannot be greater than maximum value", + ServiceScaleCurrentConfig: "Current configuration:", + ServiceScaleEnterNewValues: "Enter new values (press Enter to keep current):", + ServiceScaleInvalidValue: "invalid %s value", + ServiceScaleNoChanges: "no changes specified", + + // process list flags + ProcessListFlagStatus: "Filter by status: running, pending, finished, failed, or all.", + ProcessListFlagServiceId: "Filter by service ID.", + ProcessListFlagLimit: "Maximum number of processes to return (max 500).", + ProcessListInvalidStatus: "Invalid status '%s', must be one of: running, pending, finished, failed, all", + + // service types flags + ServiceTypesFlagCategory: "Filter by category: OBJECT_STORAGE, SHARED_STORAGE, STANDARD, or USER.", + ServiceTypesInvalidCategory: "Invalid category '%s'", + + // env set messages + EnvSetInvalidFormat: "Invalid format '%s', expected KEY=VALUE", + EnvSetEmptyKey: "empty key in '%s'", + EnvSetNoArgs: "at least one KEY=VALUE pair must be provided", + EnvSetProjectFailed: "Failed to set project environment variables", + EnvSetProjectSuccess: "Project environment variables set successfully", + EnvSetServiceFailed: "Failed to set service environment variables", + EnvSetServiceSuccess: "Service environment variables set successfully", + + // service env + CmdDescServiceEnv: "Manage service environment variables.", + CmdHelpServiceEnv: "Help for the service env command.", + + // project env + CmdDescProjectEnv: "Manage project environment variables.", + CmdHelpProjectEnv: "Help for the project env command.", + + // agent init flags + AgentInitFlagUrl: "URL to fetch the agent configuration from.", + AgentInitFlagFile: "Output file path for the agent configuration.", + AgentInitFetching: "Fetching agent configuration from: %s", + AgentInitSaved: "Agent configuration saved to: %s", + AgentInitFetchFailed: "Failed to fetch agent configuration: %s", + AgentInitWriteFailed: "Failed to write agent configuration: %s", + + // discovery + CmdDescDiscovery: "Infrastructure discovery for AI agents", + CmdHelpDiscovery: "Help for the discovery command.", + CmdDescDiscoveryList: "Returns complete infrastructure state.", + CmdHelpDiscoveryList: "Help for the discovery list command.", + DiscoveryFlagProjectId: "Filter to specific project ID.", + DiscoveryFlagServiceId: "Filter to specific service ID.", + DiscoveryFlagIncludeServiceTypes: "Include available service types catalog.", + DiscoveryFlagIncludeEnvs: "Include environment variable names for services.", } diff --git a/src/i18n/i18n.go b/src/i18n/i18n.go index 9329f037..1f223898 100644 --- a/src/i18n/i18n.go +++ b/src/i18n/i18n.go @@ -318,4 +318,100 @@ const ( ErrorInvalidScopedProjectId = "ErrorInvalidScopedProjectId" ErrorInvalidServiceId = "ErrorInvalidServiceId" ErrorServiceNotFound = "ErrorServiceNotFound" + + // output format + OutputFormatFlag = "OutputFormatFlag" + + // service scale + CmdDescServiceScale = "CmdDescServiceScale" + CmdHelpServiceScale = "CmdHelpServiceScale" + ServiceScaling = "ServiceScaling" + ServiceScaleFailed = "ServiceScaleFailed" + ServiceScaled = "ServiceScaled" + + // process + CmdDescProcess = "CmdDescProcess" + CmdHelpProcess = "CmdHelpProcess" + CmdDescProcessList = "CmdDescProcessList" + CmdHelpProcessList = "CmdHelpProcessList" + + // env set + CmdDescEnvSet = "CmdDescEnvSet" + CmdHelpEnvSet = "CmdHelpEnvSet" + + // service types + CmdDescServiceTypes = "CmdDescServiceTypes" + CmdHelpServiceTypes = "CmdHelpServiceTypes" + + // agent + CmdDescAgent = "CmdDescAgent" + CmdHelpAgent = "CmdHelpAgent" + CmdDescAgentInit = "CmdDescAgentInit" + CmdHelpAgentInit = "CmdHelpAgentInit" + + // service scale flags + ServiceScaleFlagCpuMin = "ServiceScaleFlagCpuMin" + ServiceScaleFlagCpuMax = "ServiceScaleFlagCpuMax" + ServiceScaleFlagRamMin = "ServiceScaleFlagRamMin" + ServiceScaleFlagRamMax = "ServiceScaleFlagRamMax" + ServiceScaleFlagDiskMin = "ServiceScaleFlagDiskMin" + ServiceScaleFlagDiskMax = "ServiceScaleFlagDiskMax" + ServiceScaleFlagReplicasMin = "ServiceScaleFlagReplicasMin" + ServiceScaleFlagReplicasMax = "ServiceScaleFlagReplicasMax" + ServiceScaleFlagCpuMode = "ServiceScaleFlagCpuMode" + ServiceScaleNoParams = "ServiceScaleNoParams" + ServiceScaleInvalidCpu = "ServiceScaleInvalidCpu" + ServiceScaleInvalidRam = "ServiceScaleInvalidRam" + ServiceScaleInvalidDisk = "ServiceScaleInvalidDisk" + ServiceScaleInvalidReplicas = "ServiceScaleInvalidReplicas" + ServiceScaleMinMaxError = "ServiceScaleMinMaxError" + ServiceScaleCurrentConfig = "ServiceScaleCurrentConfig" + ServiceScaleEnterNewValues = "ServiceScaleEnterNewValues" + ServiceScaleInvalidValue = "ServiceScaleInvalidValue" + ServiceScaleNoChanges = "ServiceScaleNoChanges" + + // process list flags + ProcessListFlagStatus = "ProcessListFlagStatus" + ProcessListFlagServiceId = "ProcessListFlagServiceId" + ProcessListFlagLimit = "ProcessListFlagLimit" + ProcessListInvalidStatus = "ProcessListInvalidStatus" + + // service types flags + ServiceTypesFlagCategory = "ServiceTypesFlagCategory" + ServiceTypesInvalidCategory = "ServiceTypesInvalidCategory" + + // env set flags and messages + EnvSetInvalidFormat = "EnvSetInvalidFormat" + EnvSetEmptyKey = "EnvSetEmptyKey" + EnvSetNoArgs = "EnvSetNoArgs" + EnvSetProjectFailed = "EnvSetProjectFailed" + EnvSetProjectSuccess = "EnvSetProjectSuccess" + EnvSetServiceFailed = "EnvSetServiceFailed" + EnvSetServiceSuccess = "EnvSetServiceSuccess" + + // service env + CmdDescServiceEnv = "CmdDescServiceEnv" + CmdHelpServiceEnv = "CmdHelpServiceEnv" + + // project env + CmdDescProjectEnv = "CmdDescProjectEnv" + CmdHelpProjectEnv = "CmdHelpProjectEnv" + + // agent init flags + AgentInitFlagUrl = "AgentInitFlagUrl" + AgentInitFlagFile = "AgentInitFlagFile" + AgentInitFetching = "AgentInitFetching" + AgentInitSaved = "AgentInitSaved" + AgentInitFetchFailed = "AgentInitFetchFailed" + AgentInitWriteFailed = "AgentInitWriteFailed" + + // discovery + CmdDescDiscovery = "CmdDescDiscovery" + CmdHelpDiscovery = "CmdHelpDiscovery" + CmdDescDiscoveryList = "CmdDescDiscoveryList" + CmdHelpDiscoveryList = "CmdHelpDiscoveryList" + DiscoveryFlagProjectId = "DiscoveryFlagProjectId" + DiscoveryFlagServiceId = "DiscoveryFlagServiceId" + DiscoveryFlagIncludeServiceTypes = "DiscoveryFlagIncludeServiceTypes" + DiscoveryFlagIncludeEnvs = "DiscoveryFlagIncludeEnvs" ) diff --git a/src/output/formatter.go b/src/output/formatter.go new file mode 100644 index 00000000..697edd1a --- /dev/null +++ b/src/output/formatter.go @@ -0,0 +1,45 @@ +package output + +import ( + "io" +) + +// Format defines the output format type +type Format string + +const ( + FormatTable Format = "table" + FormatJSON Format = "json" +) + +// Formatter interface for output formatting +type Formatter interface { + Format(data interface{}, writer io.Writer) error +} + +// GetFormatter returns the appropriate formatter based on the format string +func GetFormatter(format string) Formatter { + switch Format(format) { + case FormatJSON: + return &JSONFormatter{} + default: + return &TableFormatter{} + } +} + +// IsJSON returns true if the format is JSON +func IsJSON(format string) bool { + return Format(format) == FormatJSON +} + +// OutputData represents data that can be output in multiple formats +type OutputData struct { + // Data holds the raw data for JSON output + Data interface{} `json:"data"` + + // TableHeader holds column headers for table output + TableHeader []string `json:"-"` + + // TableRows holds row data for table output + TableRows [][]string `json:"-"` +} diff --git a/src/output/json.go b/src/output/json.go new file mode 100644 index 00000000..f2146724 --- /dev/null +++ b/src/output/json.go @@ -0,0 +1,16 @@ +package output + +import ( + "encoding/json" + "io" +) + +// JSONFormatter outputs data as JSON +type JSONFormatter struct{} + +// Format outputs data as formatted JSON +func (f *JSONFormatter) Format(data interface{}, writer io.Writer) error { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(data) +} diff --git a/src/output/table.go b/src/output/table.go new file mode 100644 index 00000000..941e3a8a --- /dev/null +++ b/src/output/table.go @@ -0,0 +1,32 @@ +package output + +import ( + "fmt" + "io" + + "github.com/zeropsio/zcli/src/uxBlock/models/table" +) + +// TableFormatter outputs data as a table +type TableFormatter struct{} + +// Format outputs data as a formatted table +func (f *TableFormatter) Format(data interface{}, writer io.Writer) error { + outputData, ok := data.(*OutputData) + if !ok { + // If not OutputData, try to print directly + _, err := fmt.Fprintln(writer, data) + return err + } + + // Create table + header := table.NewRowFromStrings(outputData.TableHeader...) + body := table.NewBody() + for _, row := range outputData.TableRows { + body.AddStringsRow(row...) + } + + t := table.Render(body, table.WithHeader(header)) + _, err := fmt.Fprintln(writer, t) + return err +} diff --git a/tests/e2e/process_list_test.sh b/tests/e2e/process_list_test.sh new file mode 100755 index 00000000..09676cee --- /dev/null +++ b/tests/e2e/process_list_test.sh @@ -0,0 +1,669 @@ +#!/bin/bash +# E2E testy pro příkaz: zcli process list +# +# Prerekvizity: +# - zcli je nainstalováno a v PATH (nebo použijte ZCLI_BIN) +# - Uživatel je přihlášen (zcli login) +# - Existuje projekt pro testování (nebo nastavte PROJECT_NAME) +# +# Použití: +# ./process_list_test.sh [PROJECT_NAME] +# +# Proměnné prostředí: +# ZCLI_BIN - cesta k zcli binárce (default: "zcli") +# PROJECT_NAME - název projektu pro testování + +set -e + +# Konfigurace +ZCLI_BIN="${ZCLI_BIN:-zcli}" +PROJECT_INPUT="${1:-${PROJECT_NAME:-}}" +PROJECT_ID="" +PROJECT_NAME="" +TESTS_PASSED=0 +TESTS_FAILED=0 +TESTS_SKIPPED=0 + +# Barvy pro výstup +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper funkce +print_header() { + echo -e "\n${BLUE}═══════════════════════════════════════════════════════════════${NC}" + echo -e "${BLUE} $1${NC}" + echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}\n" +} + +print_test() { + echo -e "${YELLOW}▶ TEST:${NC} $1" +} + +print_pass() { + echo -e "${GREEN}✓ PASS:${NC} $1" + ((TESTS_PASSED++)) +} + +print_fail() { + echo -e "${RED}✗ FAIL:${NC} $1" + echo -e "${RED} Detail: $2${NC}" + ((TESTS_FAILED++)) +} + +print_skip() { + echo -e "${YELLOW}⊘ SKIP:${NC} $1" + ((TESTS_SKIPPED++)) +} + +print_cmd() { + echo -e " ${BLUE}CMD:${NC} $1" +} + +# Test: Ověření, že zcli je dostupné +test_zcli_available() { + print_test "Ověření, že zcli je dostupné" + print_cmd "$ZCLI_BIN version" + + if $ZCLI_BIN version &>/dev/null; then + print_pass "zcli je dostupné" + return 0 + else + print_fail "zcli není dostupné" "Zkontrolujte, zda je zcli v PATH nebo nastavte ZCLI_BIN" + return 1 + fi +} + +# Test: Ověření, že uživatel je přihlášen +test_user_logged_in() { + print_test "Ověření, že uživatel je přihlášen" + print_cmd "$ZCLI_BIN project list --output json" + + if $ZCLI_BIN project list --output json &>/dev/null; then + print_pass "Uživatel je přihlášen" + return 0 + else + print_fail "Uživatel není přihlášen" "Spusťte: zcli login" + return 1 + fi +} + +# Převod názvu projektu na ID +resolve_project_id() { + if [ -z "$PROJECT_INPUT" ]; then + return 0 + fi + + # Pokud vstup vypadá jako ID (22 znaků), použijeme ho přímo + if [ ${#PROJECT_INPUT} -eq 22 ]; then + PROJECT_ID="$PROJECT_INPUT" + # Získáme název projektu + PROJECT_NAME=$($ZCLI_BIN project list --output json 2>/dev/null | jq -r ".[] | select(.id == \"$PROJECT_ID\") | .name" 2>/dev/null || echo "$PROJECT_ID") + echo -e "${GREEN}✓${NC} Projekt ID: $PROJECT_ID (název: $PROJECT_NAME)" + return 0 + fi + + # Jinak hledáme podle názvu + PROJECT_NAME="$PROJECT_INPUT" + + # Získáme všechny projekty se stejným názvem + local projects_json + projects_json=$($ZCLI_BIN project list --output json 2>/dev/null) + + local matching_projects + matching_projects=$(echo "$projects_json" | jq -r ".[] | select(.name == \"$PROJECT_NAME\") | .id" 2>/dev/null) + + if [ -z "$matching_projects" ]; then + echo -e "${RED}✗${NC} Projekt '$PROJECT_NAME' nebyl nalezen" + return 1 + fi + + # Počet nalezených projektů + local count + count=$(echo "$matching_projects" | wc -l | tr -d ' ') + + if [ "$count" -gt 1 ]; then + echo -e "${YELLOW}⚠${NC} Nalezeno $count projektů s názvem '$PROJECT_NAME':" + echo "" + echo "$projects_json" | jq -r ".[] | select(.name == \"$PROJECT_NAME\") | \" ID: \(.id) (vytvořen: \(.created // \"N/A\"))\"" 2>/dev/null + echo "" + echo -e "${YELLOW}Spusťte test s konkrétním ID:${NC}" + echo " $0 " + echo "" + return 1 + fi + + PROJECT_ID="$matching_projects" + echo -e "${GREEN}✓${NC} Projekt: $PROJECT_NAME (ID: $PROJECT_ID)" + return 0 +} + +# Test: Základní spuštění - process list (vyžaduje projekt) +test_basic_process_list() { + print_test "Základní process list s projektem" + print_cmd "$ZCLI_BIN process list $PROJECT_ID" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Příkaz se spustil úspěšně" + echo " Výstup (prvních 5 řádků):" + echo "$output" | head -5 | sed 's/^/ /' + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --status running +test_status_running() { + print_test "process list --status running" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status running" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status running 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Filtr status=running funguje" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --status pending +test_status_pending() { + print_test "process list --status pending" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status pending" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status pending 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Filtr status=pending funguje" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --status finished +test_status_finished() { + print_test "process list --status finished" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status finished" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status finished 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Filtr status=finished funguje" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --status failed +test_status_failed() { + print_test "process list --status failed" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status failed" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status failed 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Filtr status=failed funguje" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --status all +test_status_all() { + print_test "process list --status all" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status all" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status all 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Filtr status=all funguje" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --status invalid (očekáváme chybu) +test_status_invalid() { + print_test "process list --status invalid (očekáváme chybu)" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status neexistuje" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status neexistuje 2>&1) || exit_code=$? + + if [ $exit_code -ne 0 ]; then + print_pass "Neplatný status správně vrací chybu" + return 0 + else + print_fail "Měla by být vrácena chyba pro neplatný status" "Příkaz neočekávaně uspěl" + return 1 + fi +} + +# Test: process list --limit +test_limit() { + print_test "process list --limit 5" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status all --limit 5 --output json" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status all --limit 5 --output json 2>/dev/null) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + # Počítáme záznamy v JSON výstupu + local count + count=$(echo "$output" | jq 'length' 2>/dev/null || echo "0") + + if [ "$count" -le 5 ]; then + print_pass "Limit funguje (získáno $count záznamů, max 5)" + else + print_fail "Limit nefunguje správně" "Očekáváno max 5 záznamů, získáno: $count" + return 1 + fi + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --limit 0 (mělo by použít default 100) +test_limit_zero() { + print_test "process list --limit 0 (měl by použít default 100)" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status all --limit 0" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status all --limit 0 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Limit 0 je ošetřen (použit default)" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --limit 1000 (mělo by se ořezat na 500) +test_limit_max() { + print_test "process list --limit 1000 (měl by se ořezat na 500)" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status all --limit 1000" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status all --limit 1000 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Limit 1000 je ošetřen (ořezáno na max 500)" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list --output json +test_output_json() { + print_test "process list --output json" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --output json" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local stderr_output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --output json 2>/dev/null) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + # Zkontrolujeme, zda je výstup validní JSON + if echo "$output" | jq empty 2>/dev/null; then + print_pass "JSON výstup je validní" + echo " Výstup (první záznam):" + echo "$output" | jq '.[0] // "prázdný seznam"' | sed 's/^/ /' + return 0 + else + print_fail "Výstup není validní JSON" "$output" + return 1 + fi + else + stderr_output=$($ZCLI_BIN process list "$PROJECT_ID" --output json 2>&1 >/dev/null) + print_fail "Příkaz selhal" "$stderr_output" + return 1 + fi +} + +# Test: process list --output table (explicitně) +test_output_table() { + print_test "process list --output table" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --output table" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --output table 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + # Zkontrolujeme, zda výstup obsahuje záhlaví tabulky + if echo "$output" | grep -q "ID\|Status\|Action"; then + print_pass "Tabulkový výstup obsahuje očekávané sloupce" + else + print_pass "Tabulkový výstup se zobrazil (může být prázdný)" + fi + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: process list bez projektu (měla by být chyba) +test_no_project() { + print_test "process list bez projektu (očekáváme chybu)" + print_cmd "$ZCLI_BIN process list" + + local output + local exit_code=0 + output=$($ZCLI_BIN process list 2>&1) || exit_code=$? + + if [ $exit_code -ne 0 ]; then + print_pass "Bez projektu správně vrací chybu" + return 0 + else + # Možná má uživatel nastavený scope projekt + print_pass "Příkaz funguje (možná je nastaven projekt ve scope)" + return 0 + fi +} + +# Test: process list --service-id (s neexistujícím ID) +test_service_id_filter() { + print_test "process list --service-id (s neexistujícím ID)" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --service-id 00000000-0000-0000-0000-000000000000" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --service-id 00000000-0000-0000-0000-000000000000 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + print_pass "Filtr service-id funguje (prázdný výsledek je OK)" + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: Kombinace více parametrů +test_combined_params() { + print_test "Kombinace: --status all --limit 10 --output json" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status all --limit 10 --output json" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status all --limit 10 --output json 2>/dev/null) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + # Zkontrolujeme JSON a počet záznamů + if echo "$output" | jq empty 2>/dev/null; then + local count + count=$(echo "$output" | jq 'length') + if [ "$count" -le 10 ]; then + print_pass "Kombinace parametrů funguje (získáno $count záznamů)" + else + print_fail "Limit nefunguje správně" "Očekáváno max 10, získáno: $count" + return 1 + fi + else + print_fail "Výstup není validní JSON" "$output" + return 1 + fi + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: JSON výstup obsahuje očekávané pole +test_json_structure() { + print_test "JSON výstup obsahuje očekávaná pole" + print_cmd "$ZCLI_BIN process list $PROJECT_ID --status all --limit 1 --output json" + + if [ -z "$PROJECT_ID" ]; then + print_skip "Projekt není nastaven" + return 0 + fi + + local output + local exit_code=0 + output=$($ZCLI_BIN process list "$PROJECT_ID" --status all --limit 1 --output json 2>/dev/null) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + # Zkontrolujeme strukturu JSON + local count + count=$(echo "$output" | jq 'length') + + if [ "$count" -eq 0 ]; then + print_pass "Prázdný seznam - nelze ověřit strukturu, ale příkaz funguje" + return 0 + fi + + # Očekávaná pole: id, status, actionName, projectId, created + local has_id has_status has_action has_project has_created + has_id=$(echo "$output" | jq '.[0] | has("id")') + has_status=$(echo "$output" | jq '.[0] | has("status")') + has_action=$(echo "$output" | jq '.[0] | has("actionName")') + has_project=$(echo "$output" | jq '.[0] | has("projectId")') + has_created=$(echo "$output" | jq '.[0] | has("created")') + + if [ "$has_id" = "true" ] && [ "$has_status" = "true" ] && \ + [ "$has_action" = "true" ] && [ "$has_project" = "true" ] && \ + [ "$has_created" = "true" ]; then + print_pass "JSON obsahuje všechna povinná pole (id, status, actionName, projectId, created)" + else + print_fail "JSON neobsahuje všechna povinná pole" "id=$has_id, status=$has_status, actionName=$has_action, projectId=$has_project, created=$has_created" + return 1 + fi + return 0 + else + print_fail "Příkaz selhal" "$output" + return 1 + fi +} + +# Test: Help pro process list +test_help() { + print_test "process list --help" + print_cmd "$ZCLI_BIN process list --help" + + local output + local exit_code=0 + output=$($ZCLI_BIN process list --help 2>&1) || exit_code=$? + + if [ $exit_code -eq 0 ]; then + # Zkontrolujeme, zda help obsahuje dokumentované flagy + if echo "$output" | grep -q "status" && echo "$output" | grep -q "limit"; then + print_pass "Help obsahuje dokumentaci pro flagy" + else + print_fail "Help neobsahuje očekávané informace" "Chybí dokumentace pro --status nebo --limit" + return 1 + fi + return 0 + else + print_fail "Help selhal" "$output" + return 1 + fi +} + +# Hlavní funkce +main() { + print_header "E2E TESTY: zcli process list" + + echo "Konfigurace:" + echo " ZCLI_BIN: $ZCLI_BIN" + echo " PROJECT_INPUT: ${PROJECT_INPUT:-'(nenastaveno - některé testy budou přeskočeny)'}" + echo "" + + # Prerekvizity + print_header "1. PREREKVIZITY" + test_zcli_available || { echo -e "${RED}Kritická chyba: zcli není dostupné${NC}"; exit 1; } + test_user_logged_in || { echo -e "${RED}Kritická chyba: uživatel není přihlášen${NC}"; exit 1; } + + # Převod názvu projektu na ID + if [ -n "$PROJECT_INPUT" ]; then + resolve_project_id || { echo -e "${RED}Kritická chyba: projekt nebyl nalezen${NC}"; exit 1; } + fi + + # Základní funkčnost + print_header "2. ZÁKLADNÍ FUNKČNOST" + test_basic_process_list + test_help + + # Testy statusů + print_header "3. TESTY STATUSŮ" + test_status_running + test_status_pending + test_status_finished + test_status_failed + test_status_all + test_status_invalid + + # Testy limitu + print_header "4. TESTY LIMITU" + test_limit + test_limit_zero + test_limit_max + + # Testy výstupu + print_header "5. TESTY VÝSTUPU" + test_output_json + test_output_table + test_json_structure + + # Testy filtrů + print_header "6. TESTY FILTRŮ A KOMBINACÍ" + test_service_id_filter + test_combined_params + + # Edge cases + print_header "7. EDGE CASES" + test_no_project + + # Souhrn + print_header "SOUHRN TESTŮ" + echo -e " ${GREEN}Prošlo:${NC} $TESTS_PASSED" + echo -e " ${RED}Selhalo:${NC} $TESTS_FAILED" + echo -e " ${YELLOW}Přeskočeno:${NC} $TESTS_SKIPPED" + echo "" + + if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" + echo -e "${GREEN} VŠECHNY TESTY PROŠLY!${NC}" + echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" + exit 0 + else + echo -e "${RED}═══════════════════════════════════════════════════════════════${NC}" + echo -e "${RED} NĚKTERÉ TESTY SELHALY!${NC}" + echo -e "${RED}═══════════════════════════════════════════════════════════════${NC}" + exit 1 + fi +} + +# Spuštění +main "$@"