From a11047b37e68a67d7756b87d5e9706f338201700 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Fri, 20 Mar 2026 16:34:53 +0100 Subject: [PATCH 1/2] Add GitLab controller support with OIDC auth and config file Add GitLab platform support alongside GitHub. The CLI now detects the platform from a config file (~/.actuated/config.json) keyed by controller URL and dispatches runners/jobs commands to platform-specific implementations. GitLab authentication uses the Device Authorization Grant flow with OIDC. The short-lived id_token (~2min) is cached in config and reused when valid (with 30s leeway); otherwise it is refreshed via the stored refresh_token. Refresh tokens are rotated on each use and saved back to config. Config and OIDC field names are kept generic (URL, IDToken, RefreshToken) so the mechanism can be reused for other OIDC providers in the future. All commands now resolve the controller URL via getControllerURL() and token via getPat() which checks config before falling back to the legacy PAT file. Backward compatibility with --token/--token-value flags and the PAT file is preserved. Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- cmd/agent_logs.go | 8 +- cmd/auth.go | 295 +++++++++++++++++++++++++++++++++++++---- cmd/config.go | 255 +++++++++++++++++++++++++++++++++++ cmd/controller_logs.go | 8 +- cmd/disable.go | 8 +- cmd/gitlab_jobs.go | 130 ++++++++++++++++++ cmd/gitlab_runners.go | 61 +++++++++ cmd/increases.go | 8 +- cmd/jobs.go | 12 +- cmd/logs.go | 8 +- cmd/metering.go | 8 +- cmd/platform.go | 51 +++++++ cmd/repair.go | 8 +- cmd/restart.go | 8 +- cmd/root.go | 80 +++++++---- cmd/runners.go | 13 +- cmd/upgrade.go | 8 +- pkg/client.go | 96 ++++++++++++++ 18 files changed, 995 insertions(+), 70 deletions(-) create mode 100644 cmd/config.go create mode 100644 cmd/gitlab_jobs.go create mode 100644 cmd/gitlab_runners.go create mode 100644 cmd/platform.go diff --git a/cmd/agent_logs.go b/cmd/agent_logs.go index b19ef00..868fcff 100644 --- a/cmd/agent_logs.go +++ b/cmd/agent_logs.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "strings" "time" @@ -73,7 +72,12 @@ func runAgentLogsE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.GetAgentLogs(pat, owner, host, age, staff) diff --git a/cmd/auth.go b/cmd/auth.go index 55d95b2..271f582 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -20,18 +20,149 @@ import ( func makeAuth() *cobra.Command { cmd := &cobra.Command{ Use: "auth", - Short: "Authenticate to GitHub to obtain a token and save it to $HOME/.actuated/PAT", + Short: "Authenticate and save credentials to the config file", + Example: ` # Authenticate with GitHub (default) + actuated-cli auth + + # Authenticate with GitLab (gitlab.com) + actuated-cli auth --platform gitlab + + # Authenticate with a self-managed GitLab instance + actuated-cli auth --platform gitlab --gitlab-url https://gitlab.example.com +`, } cmd.RunE = runAuthE + cmd.Flags().String("platform", "", "Platform to authenticate with (github or gitlab)") + cmd.Flags().String("url", "", "URL of the actuated controller (can also be set via ACTUATED_URL env var)") + cmd.Flags().String("gitlab-url", "https://gitlab.com", "GitLab instance URL for authentication") + cmd.Flags().String("client-id", "", "OAuth application client ID for GitLab authentication") + return cmd } func runAuthE(cmd *cobra.Command, args []string) error { - token := "" + platform, err := cmd.Flags().GetString("platform") + if err != nil { + return err + } + + if len(platform) == 0 { + platform = PlatformGitHub + } + + if err := validatePlatform(platform); err != nil { + return err + } + + // Resolve the controller URL from --url flag or ACTUATED_URL env var + controllerURL, err := cmd.Flags().GetString("url") + if err != nil { + return err + } + + if controllerURL == "" { + controllerURL = os.Getenv("ACTUATED_URL") + } + + if controllerURL == "" { + return fmt.Errorf("controller URL is required, set --url flag or ACTUATED_URL environment variable") + } + + controllerURL = normalizeURL(controllerURL) + + var token string + + switch platform { + case PlatformGitHub: + token, err = runGitHubAuth() + if err != nil { + return err + } + + if len(token) == 0 { + return fmt.Errorf("no token was obtained") + } + + // Save to config file + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + cfg.Controllers[controllerURL] = ControllerConfig{ + Platform: platform, + Token: token, + } + + if err := saveConfig(cfg); err != nil { + return err + } + + // Also write to legacy PAT file for backward compatibility + os.MkdirAll(os.ExpandEnv(basePath), 0755) + if err := os.WriteFile(os.ExpandEnv(path.Join(basePath, "PAT")), []byte(token), 0600); err != nil { + return fmt.Errorf("writing legacy PAT file: %w", err) + } + + case PlatformGitLab: + gitlabURL, err := cmd.Flags().GetString("gitlab-url") + if err != nil { + return err + } + clientID, err := cmd.Flags().GetString("client-id") + if err != nil { + return err + } + + tokenRes, err := runGitLabAuth(gitlabURL, clientID) + if err != nil { + return err + } + + if tokenRes.RefreshToken == "" { + return fmt.Errorf("no refresh_token was obtained from GitLab") + } + + // Save refresh token, client_id, and gitlab_url to config. + // The short-lived id_token will be obtained via refresh before each API call. + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + resolvedClientID := clientID + if resolvedClientID == "" { + resolvedClientID = gitlabDefaultClientID + } + + cfg.Controllers[controllerURL] = ControllerConfig{ + Platform: platform, + RefreshToken: tokenRes.RefreshToken, + IDToken: tokenRes.IDToken, + ClientID: resolvedClientID, + URL: gitlabURL, + } + + if err := saveConfig(cfg); err != nil { + return err + } + } + + if err != nil { + return err + } + + fmt.Printf("Credentials saved to: %s\n", configFilePath()) + fmt.Printf(" Controller: %s\n", controllerURL) + fmt.Printf(" Platform: %s\n", platform) + return nil +} + +func runGitHubAuth() (string, error) { clientID := "8c5dc5d9750ff2a8396a" dcParams := url.Values{} @@ -40,9 +171,8 @@ func runAuthE(cmd *cobra.Command, args []string) error { dcParams.Set("scope", "read:user,read:org,user:email") req, err := http.NewRequest(http.MethodPost, "https://github.com/login/device/code", bytes.NewBuffer([]byte(dcParams.Encode()))) - if err != nil { - return err + return "", err } req.Header.Set("Accept", "application/json") @@ -50,19 +180,19 @@ func runAuthE(cmd *cobra.Command, args []string) error { res, err := http.DefaultClient.Do(req) if err != nil { - return err + return "", err } body, _ := io.ReadAll(res.Body) if res.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, string(body)) + return "", fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, string(body)) } auth := DeviceAuth{} if err := json.Unmarshal(body, &auth); err != nil { - return err + return "", err } fmt.Printf("Please visit: %s\n", auth.VerificationURI) @@ -74,48 +204,143 @@ func runAuthE(cmd *cobra.Command, args []string) error { urlv.Set("device_code", auth.DeviceCode) urlv.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", bytes.NewBuffer([]byte(urlv.Encode()))) if err != nil { - return err + return "", err } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := http.DefaultClient.Do(req) if err != nil { - return err + return "", err } body, _ := io.ReadAll(res.Body) parts, err := url.ParseQuery(string(body)) if err != nil { - return err + return "", err } if parts.Get("error") == "authorization_pending" { fmt.Println("Waiting for authorization...") time.Sleep(time.Second * 5) continue } else if parts.Get("access_token") != "" { - // fmt.Println(parts) - token = parts.Get("access_token") - - break + return parts.Get("access_token"), nil } else { - return fmt.Errorf("something went wrong") + return "", fmt.Errorf("something went wrong") } } - const basePath = "$HOME/.actuated" - os.Mkdir(os.ExpandEnv(basePath), 0755) + return "", fmt.Errorf("timed out waiting for authorization") +} - if err := os.WriteFile(os.ExpandEnv(path.Join(basePath, "PAT")), []byte(token), 0644); err != nil { - return err +const gitlabDefaultClientID = "222c0ecd207277ddd78864e94f72709663babf81dfd70513cfb82334ba4a8a2a" + +func runGitLabAuth(gitlabURL, clientID string) (*GitLabTokenResponse, error) { + if len(clientID) == 0 { + clientID = gitlabDefaultClientID } - fmt.Printf("Access token written to: %s\n", os.ExpandEnv(path.Join(basePath, "PAT"))) + deviceURL := fmt.Sprintf("%s/oauth/authorize_device", gitlabURL) + tokenURL := fmt.Sprintf("%s/oauth/token", gitlabURL) - return nil + dcParams := url.Values{} + dcParams.Set("client_id", clientID) + dcParams.Set("scope", "openid") + + req, err := http.NewRequest(http.MethodPost, deviceURL, bytes.NewBuffer([]byte(dcParams.Encode()))) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + body, _ := io.ReadAll(res.Body) + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code from GitLab device auth: %d, body: %s", res.StatusCode, string(body)) + } + + auth := GitLabDeviceAuth{} + if err := json.Unmarshal(body, &auth); err != nil { + return nil, err + } + + fmt.Printf("Please visit: %s\n", auth.VerificationURI) + fmt.Printf("and enter the code: %s\n", auth.UserCode) + + interval := auth.Interval + if interval < 5 { + interval = 5 + } + + maxAttempts := auth.ExpiresIn / interval + if maxAttempts <= 0 { + maxAttempts = 60 + } + + for i := 0; i < maxAttempts; i++ { + time.Sleep(time.Duration(interval) * time.Second) + + tokenParams := url.Values{} + tokenParams.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + tokenParams.Set("device_code", auth.DeviceCode) + tokenParams.Set("client_id", clientID) + + req, err := http.NewRequest(http.MethodPost, tokenURL, bytes.NewBuffer([]byte(tokenParams.Encode()))) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + body, _ := io.ReadAll(res.Body) + + var tokenRes GitLabTokenResponse + if err := json.Unmarshal(body, &tokenRes); err != nil { + return nil, err + } + + if tokenRes.Error == "authorization_pending" { + fmt.Println("Waiting for authorization...") + continue + } else if tokenRes.Error == "slow_down" { + interval += 5 + fmt.Println("Waiting for authorization...") + continue + } else if tokenRes.Error == "expired_token" { + return nil, fmt.Errorf("device code expired, please try again") + } else if tokenRes.Error == "access_denied" { + return nil, fmt.Errorf("authorization request was denied") + } else if len(tokenRes.Error) > 0 { + return nil, fmt.Errorf("error from GitLab: %s - %s", tokenRes.Error, tokenRes.ErrorDescription) + } + + if len(tokenRes.IDToken) > 0 { + return &tokenRes, nil + } + + if len(tokenRes.AccessToken) > 0 { + return nil, fmt.Errorf("received access_token but no id_token, ensure the OAuth app has the openid scope enabled") + } + + return nil, fmt.Errorf("unexpected response from GitLab token endpoint") + } + + return nil, fmt.Errorf("timed out waiting for authorization") } // DeviceAuth is the device auth response from GitHub and is @@ -127,3 +352,27 @@ type DeviceAuth struct { ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } + +// GitLabDeviceAuth is the device authorization response from GitLab. +type GitLabDeviceAuth struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// GitLabTokenResponse is the token response from GitLab's OAuth token endpoint. +// When the openid scope is requested, the response includes an id_token (JWT). +type GitLabTokenResponse struct { + AccessToken string `json:"access_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` + IDToken string `json:"id_token,omitempty"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..3b7740e --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,255 @@ +package cmd + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" +) + +const configFileName = "config.json" + +// CLIConfig is the top-level configuration for the actuated CLI. +// It stores per-controller settings keyed by the controller URL. +type CLIConfig struct { + Controllers map[string]ControllerConfig `json:"controllers"` +} + +// ControllerConfig holds the configuration for a single actuated controller. +type ControllerConfig struct { + Platform string `json:"platform"` + Token string `json:"token,omitempty"` + + // OIDC fields: used to refresh short-lived id_tokens (JWTs). + // Currently used for GitLab OIDC authentication. + // The refresh_token is long-lived and may be rotated on each refresh. + RefreshToken string `json:"refresh_token,omitempty"` + ClientID string `json:"client_id,omitempty"` + URL string `json:"url,omitempty"` + + // IDToken is a cached OIDC id_token (JWT). It has a short validity + // (e.g. ~2 minutes for GitLab) but is reused when still valid to + // avoid unnecessary refresh calls. + IDToken string `json:"id_token,omitempty"` +} + +// configFilePath returns the full path to the config file. +func configFilePath() string { + return os.ExpandEnv(path.Join(basePath, configFileName)) +} + +// loadConfig reads the config file from disk. +// Returns an empty config (not an error) if the file does not exist. +func loadConfig() (*CLIConfig, error) { + data, err := os.ReadFile(configFilePath()) + if err != nil { + if os.IsNotExist(err) { + return &CLIConfig{ + Controllers: make(map[string]ControllerConfig), + }, nil + } + return nil, fmt.Errorf("reading config file: %w", err) + } + + var cfg CLIConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + if cfg.Controllers == nil { + cfg.Controllers = make(map[string]ControllerConfig) + } + + return &cfg, nil +} + +// saveConfig writes the config to disk, creating the directory if needed. +func saveConfig(cfg *CLIConfig) error { + os.MkdirAll(os.ExpandEnv(basePath), 0755) + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshalling config: %w", err) + } + + if err := os.WriteFile(configFilePath(), data, 0600); err != nil { + return fmt.Errorf("writing config file: %w", err) + } + + return nil +} + +// normalizeURL ensures the URL has no trailing slash for consistent map keys. +func normalizeURL(u string) string { + return strings.TrimRight(u, "/") +} + +// getControllerURL returns the actuated controller URL. +// It reads from the ACTUATED_URL environment variable. +func getControllerURL() (string, error) { + v, ok := os.LookupEnv("ACTUATED_URL") + if !ok || v == "" { + return "", fmt.Errorf("ACTUATED_URL environment variable is not set, see the CLI tab in the dashboard for instructions") + } + + if strings.Contains(v, "o6s.io") { + return "", fmt.Errorf("the ACTUATED_URL loaded from your shell is out of date, visit https://dashboard.actuated.com and click \"CLI\" for the latest URL and edit export ACTUATED_URL=... in your bash or zsh profile") + } + + return normalizeURL(v), nil +} + +// getControllerConfig returns the controller config for the current controller URL. +// If no config entry exists for the URL, it returns a zero-value ControllerConfig +// and found=false. +func getControllerConfig() (ControllerConfig, string, bool, error) { + controllerURL, err := getControllerURL() + if err != nil { + return ControllerConfig{}, "", false, err + } + + cfg, err := loadConfig() + if err != nil { + return ControllerConfig{}, controllerURL, false, err + } + + cc, found := cfg.Controllers[controllerURL] + return cc, controllerURL, found, nil +} + +// jwtLeeway is subtracted from the token's expiry time to ensure the +// token is still usable by the upstream API when it arrives. +const jwtLeeway = 30 * time.Second + +// isIDTokenValid checks whether a cached id_token (JWT) is still valid. +// It decodes the payload (without signature verification — the controller +// does that) and compares the "exp" claim against the current time minus +// a leeway. Returns true if the token can be reused. +func isIDTokenValid(idToken string) bool { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return false + } + + // JWT payload is base64url-encoded without padding + payload := parts[1] + // Add padding if needed + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + return false + } + + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(decoded, &claims); err != nil { + return false + } + + if claims.Exp == 0 { + return false + } + + expiry := time.Unix(claims.Exp, 0) + return time.Now().Add(jwtLeeway).Before(expiry) +} + +// refreshOIDCToken uses the stored refresh_token to obtain a fresh id_token +// from the OIDC token endpoint. It also updates the config file with the +// rotated refresh_token. Currently used for GitLab OIDC authentication. +// +// Returns the new id_token (JWT) to use as a bearer token. +func refreshOIDCToken(controllerURL string, cc ControllerConfig) (string, error) { + if cc.RefreshToken == "" { + return "", fmt.Errorf("no refresh_token stored for %s, run \"actuated-cli auth --url %s\" to re-authenticate", controllerURL, controllerURL) + } + + if cc.URL == "" { + return "", fmt.Errorf("no url stored for %s, run \"actuated-cli auth --url %s\" to re-authenticate", controllerURL, controllerURL) + } + + if cc.ClientID == "" { + return "", fmt.Errorf("no client_id stored for %s, run \"actuated-cli auth --url %s\" to re-authenticate", controllerURL, controllerURL) + } + + tokenURL := fmt.Sprintf("%s/oauth/token", cc.URL) + + params := url.Values{} + params.Set("grant_type", "refresh_token") + params.Set("refresh_token", cc.RefreshToken) + params.Set("client_id", cc.ClientID) + + req, err := http.NewRequest(http.MethodPost, tokenURL, bytes.NewBuffer([]byte(params.Encode()))) + if err != nil { + return "", fmt.Errorf("creating refresh request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("refreshing OIDC token: %w", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf("reading refresh response: %w", err) + } + + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("OIDC token refresh failed (HTTP %d): %s\nRun \"actuated-cli auth --url %s\" to re-authenticate", + res.StatusCode, string(body), controllerURL) + } + + var tokenRes struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.Unmarshal(body, &tokenRes); err != nil { + return "", fmt.Errorf("parsing refresh response: %w", err) + } + + if tokenRes.Error != "" { + return "", fmt.Errorf("OIDC token refresh error: %s - %s\nRun \"actuated-cli auth --url %s\" to re-authenticate", + tokenRes.Error, tokenRes.ErrorDesc, controllerURL) + } + + if tokenRes.IDToken == "" { + return "", fmt.Errorf("refresh response did not include id_token, ensure the OAuth app has the openid scope") + } + + // Update the config with the rotated refresh token and cached id_token + cfg, err := loadConfig() + if err != nil { + return "", fmt.Errorf("loading config for refresh token update: %w", err) + } + + updated := cfg.Controllers[controllerURL] + updated.RefreshToken = tokenRes.RefreshToken + updated.IDToken = tokenRes.IDToken + cfg.Controllers[controllerURL] = updated + + if err := saveConfig(cfg); err != nil { + return "", fmt.Errorf("saving rotated refresh token: %w", err) + } + + return tokenRes.IDToken, nil +} diff --git a/cmd/controller_logs.go b/cmd/controller_logs.go index 81e0534..f2ad8f8 100644 --- a/cmd/controller_logs.go +++ b/cmd/controller_logs.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "time" "github.com/self-actuated/actuated-cli/pkg" @@ -47,7 +46,12 @@ func runControllerLogsE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.GetControllerLogs(pat, outputFormat, age) diff --git a/cmd/disable.go b/cmd/disable.go index 404fff9..cd3cca7 100644 --- a/cmd/disable.go +++ b/cmd/disable.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "strings" "github.com/self-actuated/actuated-cli/pkg" @@ -59,7 +58,12 @@ func runDisableE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.DisableAgent(pat, owner, host, staff) if err != nil { diff --git a/cmd/gitlab_jobs.go b/cmd/gitlab_jobs.go new file mode 100644 index 0000000..5f12422 --- /dev/null +++ b/cmd/gitlab_jobs.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/olekukonko/tablewriter" + "github.com/self-actuated/actuated-cli/pkg" + "github.com/spf13/cobra" +) + +func runGitLabJobsE(cmd *cobra.Command, args []string) error { + + var namespace string + if len(args) == 1 { + namespace = strings.TrimSpace(args[0]) + } + + pat, err := getPat(cmd) + if err != nil { + return err + } + + requestJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + + if len(pat) == 0 { + return fmt.Errorf("pat is required") + } + + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) + + acceptJSON := true + + res, status, err := c.GitLabListJobs(pat, namespace, acceptJSON) + if err != nil { + return err + } + + if status != http.StatusOK { + return fmt.Errorf("unexpected status code: %d, message: %s", status, string(res)) + } + + if requestJSON { + var prettyJSON bytes.Buffer + err := json.Indent(&prettyJSON, []byte(res), "", " ") + if err != nil { + return err + } + res = prettyJSON.String() + fmt.Println(res) + } else { + var statuses []GitLabJobStatus + if err := json.Unmarshal([]byte(res), &statuses); err != nil { + return err + } + + printGitLabJobs(os.Stdout, statuses) + } + + return nil +} + +func printGitLabJobs(w io.Writer, statuses []GitLabJobStatus) { + table := tablewriter.NewWriter(w) + + table.SetHeader([]string{"JOB ID", "NAMESPACE/PROJECT", "JOB NAME", "STATUS", "RUNNER", "LABELS"}) + + table.SetBorders(tablewriter.Border{Left: true, Top: true, Right: true, Bottom: true}) + table.SetCenterSeparator("|") + table.SetColumnSeparator("|") + table.SetRowSeparator("-") + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(false) + + for _, s := range statuses { + runner := s.RunnerName + if runner == "" { + runner = "-" + } + + labels := "" + if len(s.Labels) > 0 { + labels = strings.Join(s.Labels, ",") + } + + table.Append([]string{ + fmt.Sprintf("%d", s.JobID), + fmt.Sprintf("%s/%s", s.Namespace, s.Project), + s.JobName, + s.Status, + runner, + labels, + }) + } + + table.Render() +} + +// GitLabJobStatus represents a CI job in the GitLab build queue. +type GitLabJobStatus struct { + JobID int64 `json:"job_id"` + PipelineID int64 `json:"pipeline_id"` + NamespaceID int64 `json:"namespace_id"` + Namespace string `json:"namespace"` + ProjectID int64 `json:"project_id"` + Project string `json:"project"` + JobName string `json:"job_name"` + RunnerID int64 `json:"runner_id,omitempty"` + RunnerName string `json:"runner_name,omitempty"` + TriggeredBy string `json:"triggered_by,omitempty"` + Status string `json:"status"` + Labels []string `json:"labels,omitempty"` + UpdatedAt *time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} diff --git a/cmd/gitlab_runners.go b/cmd/gitlab_runners.go new file mode 100644 index 0000000..50b5061 --- /dev/null +++ b/cmd/gitlab_runners.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/self-actuated/actuated-cli/pkg" + "github.com/spf13/cobra" +) + +func runGitLabRunnersE(cmd *cobra.Command, args []string) error { + + images, err := cmd.Flags().GetBool("images") + if err != nil { + return err + } + + pat, err := getPat(cmd) + if err != nil { + return err + } + + requestJSON, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + + if len(pat) == 0 { + return fmt.Errorf("pat is required") + } + + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) + + res, status, err := c.GitLabListRunners(pat, images, requestJSON) + if err != nil { + return err + } + + if status != http.StatusOK { + return fmt.Errorf("unexpected status code: %d, message: %s", status, res) + } + + if requestJSON { + var prettyJSON bytes.Buffer + err := json.Indent(&prettyJSON, []byte(res), "", " ") + if err != nil { + return err + } + res = prettyJSON.String() + } + fmt.Println(res) + + return nil +} diff --git a/cmd/increases.go b/cmd/increases.go index 537be20..3c72f99 100644 --- a/cmd/increases.go +++ b/cmd/increases.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "strings" "time" @@ -53,7 +52,12 @@ func runIncreasesE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) days, err := cmd.Flags().GetInt("days") if err != nil { return err diff --git a/cmd/jobs.go b/cmd/jobs.go index cab0176..507e9e2 100644 --- a/cmd/jobs.go +++ b/cmd/jobs.go @@ -63,6 +63,11 @@ end if you reach out to support. func runJobsE(cmd *cobra.Command, args []string) error { + platform := getPlatform() + if platform == PlatformGitLab { + return runGitLabJobsE(cmd, args) + } + var owner string if len(args) == 1 { owner = strings.TrimSpace(args[0]) @@ -92,7 +97,12 @@ func runJobsE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) acceptJSON := true diff --git a/cmd/logs.go b/cmd/logs.go index 25a3edc..f594eb4 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "strings" "time" @@ -96,7 +95,12 @@ func runLogsE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.GetLogs(pat, owner, host, id, age, staff) diff --git a/cmd/metering.go b/cmd/metering.go index e051de9..7acf8ae 100644 --- a/cmd/metering.go +++ b/cmd/metering.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "strings" "github.com/self-actuated/actuated-cli/pkg" @@ -72,7 +71,12 @@ func runMeteringE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.GetMetering(pat, owner, host, id, staff) if err != nil { diff --git a/cmd/platform.go b/cmd/platform.go new file mode 100644 index 0000000..e93b23f --- /dev/null +++ b/cmd/platform.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + "os" + "path" + "strings" +) + +const ( + PlatformGitHub = "github" + PlatformGitLab = "gitlab" +) + +const basePath = "$HOME/.actuated" + +// getPlatform returns the platform for the current controller. +// It first checks the config file for a controller entry matching the +// ACTUATED_URL. If not found, it falls back to the legacy $HOME/.actuated/PLATFORM file. +// Defaults to "github" if neither source has a value. +func getPlatform() string { + cc, _, found, err := getControllerConfig() + if err == nil && found && cc.Platform != "" { + return cc.Platform + } + + // Fallback: read legacy PLATFORM file + platformFile := os.ExpandEnv(path.Join(basePath, "PLATFORM")) + + data, err := os.ReadFile(platformFile) + if err != nil { + return PlatformGitHub + } + + platform := strings.TrimSpace(string(data)) + if platform == PlatformGitLab { + return PlatformGitLab + } + + return PlatformGitHub +} + +// validatePlatform checks that the given platform string is valid. +func validatePlatform(platform string) error { + switch platform { + case PlatformGitHub, PlatformGitLab: + return nil + default: + return fmt.Errorf("unsupported platform: %q, supported values: %s, %s", platform, PlatformGitHub, PlatformGitLab) + } +} diff --git a/cmd/repair.go b/cmd/repair.go index 4ba8ca6..a311e0e 100644 --- a/cmd/repair.go +++ b/cmd/repair.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "strings" "github.com/self-actuated/actuated-cli/pkg" @@ -58,7 +57,12 @@ func runRepairE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.Repair(pat, owner, staff) if err != nil { diff --git a/cmd/restart.go b/cmd/restart.go index e04d44d..f278fbd 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "strings" "github.com/self-actuated/actuated-cli/pkg" @@ -67,7 +66,12 @@ func runRestartE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.RestartAgent(pat, owner, host, reboot, staff) if err != nil { diff --git a/cmd/root.go b/cmd/root.go index 4faf3b1..c2d1703 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,10 +18,15 @@ func init() { Long: `This CLI can be used to review and manage jobs, and the actuated agent installed on your servers. +For GitHub: The --owner flag or OWNER argument is a GitHub organization, i.e. for the path: self-actuated/actuated-cli, the owner is "self-actuated" also known as an org. -Run "actuated-cli auth" to authenticate with GitHub. +For GitLab: +The NAMESPACE argument is a GitLab namespace (group), used for filtering jobs. + +Run "actuated-cli auth --url URL" to authenticate with GitHub. +Run "actuated-cli auth --platform gitlab --url URL" to authenticate with GitLab. Learn more: https://docs.actuated.com/tasks/cli/ @@ -32,16 +37,18 @@ https://github.com/self-actuated/actuated-cli } root.PersistentFlags().String("token-value", "", "Personal Access Token") - root.PersistentFlags().StringP("token", "t", "$HOME/.actuated/PAT", "File to read for Personal Access Token") + root.PersistentFlags().StringP("token", "t", "$HOME/.actuated/PAT", "File to read for Personal Access Token (legacy fallback)") root.PersistentFlags().BoolP("staff", "s", false, "Execute the command as an actuated staff member") root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - if v, ok := os.LookupEnv("ACTUATED_URL"); !ok || v == "" { - return fmt.Errorf(`ACTUATED_URL environment variable is not set, see the CLI tab in the dashboard for instructions`) - } else if strings.Contains(v, "o6s.io") { - return fmt.Errorf("the ACTUATED_URL loaded from your shell is out of date, visit https://dashboard.actuated.com and click \"CLI\" for the latest URL and edit export ACTUATED_URL=... in your bash or zsh profile") + // Skip URL validation for commands that don't need it + cmdName := cmd.Name() + if cmdName == "auth" || cmdName == "version" { + return nil } - return nil + + _, err := getControllerURL() + return err } root.AddCommand(makeAuth()) @@ -67,36 +74,57 @@ func Execute() error { return root.Execute() } +// getPat returns the authentication token for the current controller. +// Resolution order: +// 1. --token-value flag (explicit value) +// 2. Config file entry for the current ACTUATED_URL +// 3. --token flag / legacy PAT file func getPat(cmd *cobra.Command) (string, error) { - var ( - pat, - patFile string - ) - + // 1. Explicit --token-value flag takes highest priority if cmd.Flags().Changed("token-value") { v, err := cmd.Flags().GetString("token-value") if err != nil { return "", err } - pat = v - } else { - v, err := cmd.Flags().GetString("token") - if err != nil { - return "", err + return v, nil + } + + // 2. Try config file for the current controller URL + cc, controllerURL, found, err := getControllerConfig() + if err == nil && found { + // For GitHub: use the stored token directly + if cc.Token != "" { + return cc.Token, nil } - if len(v) == 0 { - return "", fmt.Errorf("give --token or --token-value") + // For GitLab: use the cached id_token if still valid, otherwise refresh + if cc.Platform == PlatformGitLab && cc.RefreshToken != "" { + if cc.IDToken != "" && isIDTokenValid(cc.IDToken) { + return cc.IDToken, nil + } + + idToken, err := refreshOIDCToken(controllerURL, cc) + if err != nil { + return "", err + } + return idToken, nil } - patFile = os.ExpandEnv(v) } - if len(patFile) > 0 { - v, err := readPatFile(patFile) - if err != nil { - return "", err - } - pat = v + // 3. Fall back to legacy --token flag / PAT file + v, err := cmd.Flags().GetString("token") + if err != nil { + return "", err + } + + if len(v) == 0 { + return "", fmt.Errorf("no token found: run \"actuated-cli auth --url URL\" to authenticate, or use --token-value") + } + + patFile := os.ExpandEnv(v) + pat, err := readPatFile(patFile) + if err != nil { + return "", err } return pat, nil diff --git a/cmd/runners.go b/cmd/runners.go index f434650..9225bae 100644 --- a/cmd/runners.go +++ b/cmd/runners.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "strings" "github.com/self-actuated/actuated-cli/pkg" @@ -37,6 +36,11 @@ func makeRunners() *cobra.Command { func runRunnersE(cmd *cobra.Command, args []string) error { + platform := getPlatform() + if platform == PlatformGitLab { + return runGitLabRunnersE(cmd, args) + } + images, err := cmd.Flags().GetBool("images") if err != nil { return err @@ -66,7 +70,12 @@ func runRunnersE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) res, status, err := c.ListRunners(pat, owner, staff, images, requestJson) if err != nil { diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 4376444..8c0a0d4 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "strings" "time" @@ -81,7 +80,12 @@ func runUpgradeE(cmd *cobra.Command, args []string) error { return fmt.Errorf("pat is required") } - c := pkg.NewClient(http.DefaultClient, os.Getenv("ACTUATED_URL")) + controllerURL, err := getControllerURL() + if err != nil { + return err + } + + c := pkg.NewClient(http.DefaultClient, controllerURL) var upgradeHosts []Host if allHosts { diff --git a/pkg/client.go b/pkg/client.go index 703d618..618fa06 100644 --- a/pkg/client.go +++ b/pkg/client.go @@ -617,3 +617,99 @@ func (c *Client) DisableAgent(patStr, owner, host string, staff bool) (string, i return string(body), res.StatusCode, nil } + +func (c *Client) GitLabListRunners(patStr string, images, requestJSON bool) (string, int, error) { + + u, _ := url.Parse(c.baseURL) + u.Path = "/api/v1/runners" + q := u.Query() + + if images { + q.Set("images", "1") + } + + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", http.StatusBadRequest, err + } + + if requestJSON { + req.Header.Set("Accept", "application/json") + } + + req.Header.Set("Authorization", "Bearer "+patStr) + + if os.Getenv("DEBUG") == "1" { + sanitised := http.Header{} + for k, v := range req.Header { + if k == "Authorization" { + v = []string{"redacted"} + } + sanitised[k] = v + } + fmt.Printf("URL %s\nHeaders: %v\n", u.String(), sanitised) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return "", http.StatusServiceUnavailable, err + } + + var body []byte + if res.Body != nil { + defer res.Body.Close() + body, _ = io.ReadAll(res.Body) + } + + return string(body), res.StatusCode, nil +} + +func (c *Client) GitLabListJobs(patStr string, namespace string, requestJSON bool) (string, int, error) { + + u, _ := url.Parse(c.baseURL) + u.Path = "/api/v1/job-queue" + q := u.Query() + + if len(namespace) > 0 { + q.Set("namespace", namespace) + } + + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", http.StatusBadRequest, err + } + + if requestJSON { + req.Header.Set("Accept", "application/json") + } + + req.Header.Set("Authorization", "Bearer "+patStr) + + if os.Getenv("DEBUG") == "1" { + sanitised := http.Header{} + for k, v := range req.Header { + if k == "Authorization" { + v = []string{"redacted"} + } + sanitised[k] = v + } + fmt.Printf("URL %s\nHeaders: %v\n", u.String(), sanitised) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return "", http.StatusServiceUnavailable, err + } + + var body []byte + if res.Body != nil { + defer res.Body.Close() + body, _ = io.ReadAll(res.Body) + } + + return string(body), res.StatusCode, nil +} From 624a1cc6ceba83620d18285bbf4ae136991991e5 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Fri, 20 Mar 2026 16:50:37 +0100 Subject: [PATCH 2/2] Return clear error for commands not yet supported on GitLab Add checkGitHubOnly() guard to the 11 commands that only have GitHub implementations. Running them with a GitLab controller now returns: "the X command is not supported for platform gitlab". Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- cmd/agent_logs.go | 4 ++++ cmd/controller_logs.go | 3 +++ cmd/disable.go | 4 ++++ cmd/increases.go | 3 +++ cmd/logs.go | 4 ++++ cmd/metering.go | 4 ++++ cmd/platform.go | 10 ++++++++++ cmd/repair.go | 3 +++ cmd/restart.go | 4 ++++ cmd/ssh_connect.go | 4 ++++ cmd/ssh_ls.go | 3 +++ cmd/upgrade.go | 3 +++ 12 files changed, 49 insertions(+) diff --git a/cmd/agent_logs.go b/cmd/agent_logs.go index 868fcff..a3d04c2 100644 --- a/cmd/agent_logs.go +++ b/cmd/agent_logs.go @@ -35,6 +35,10 @@ VM launches.`, } func runAgentLogsE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("agent-logs"); err != nil { + return err + } + if len(args) < 1 { return fmt.Errorf("specify the host as an argument") } diff --git a/cmd/controller_logs.go b/cmd/controller_logs.go index f2ad8f8..d926ff7 100644 --- a/cmd/controller_logs.go +++ b/cmd/controller_logs.go @@ -27,6 +27,9 @@ func makeControllerLogs() *cobra.Command { } func runControllerLogsE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("controller logs"); err != nil { + return err + } pat, err := getPat(cmd) if err != nil { diff --git a/cmd/disable.go b/cmd/disable.go index cd3cca7..cad571c 100644 --- a/cmd/disable.go +++ b/cmd/disable.go @@ -30,6 +30,10 @@ SSH and running "systemctl enable actuated".`, } func runDisableE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("disable"); err != nil { + return err + } + if len(args) < 1 { return fmt.Errorf("specify the host as an argument") } diff --git a/cmd/increases.go b/cmd/increases.go index 3c72f99..5b528ba 100644 --- a/cmd/increases.go +++ b/cmd/increases.go @@ -27,6 +27,9 @@ func makeIncreases() *cobra.Command { } func runIncreasesE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("increases"); err != nil { + return err + } var owner string if len(args) == 1 { diff --git a/cmd/logs.go b/cmd/logs.go index f594eb4..a41e627 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -56,6 +56,10 @@ func preRunLogsE(cmd *cobra.Command, args []string) error { } func runLogsE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("logs"); err != nil { + return err + } + host := strings.TrimSpace(args[0]) pat, err := getPat(cmd) diff --git a/cmd/metering.go b/cmd/metering.go index 7acf8ae..3c8a4f2 100644 --- a/cmd/metering.go +++ b/cmd/metering.go @@ -34,6 +34,10 @@ actuated-cli metering --owner=OWNER --id=ID HOST | vmmeter } func runMeteringE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("metering"); err != nil { + return err + } + if len(args) < 1 { return fmt.Errorf("specify the host as an argument") } diff --git a/cmd/platform.go b/cmd/platform.go index e93b23f..05fef1c 100644 --- a/cmd/platform.go +++ b/cmd/platform.go @@ -49,3 +49,13 @@ func validatePlatform(platform string) error { return fmt.Errorf("unsupported platform: %q, supported values: %s, %s", platform, PlatformGitHub, PlatformGitLab) } } + +// checkGitHubOnly returns an error if the current platform is not GitHub. +// Use this at the start of command handlers that have not been implemented +// for other platforms yet. +func checkGitHubOnly(commandName string) error { + if p := getPlatform(); p != PlatformGitHub { + return fmt.Errorf("the %q command is not supported for platform %q", commandName, p) + } + return nil +} diff --git a/cmd/repair.go b/cmd/repair.go index a311e0e..1854cdf 100644 --- a/cmd/repair.go +++ b/cmd/repair.go @@ -33,6 +33,9 @@ status.`, } func runRepairE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("repair"); err != nil { + return err + } var owner string if len(args) == 1 { diff --git a/cmd/restart.go b/cmd/restart.go index f278fbd..e07c9db 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -33,6 +33,10 @@ func makeRestart() *cobra.Command { } func runRestartE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("restart"); err != nil { + return err + } + if len(args) < 1 { return fmt.Errorf("specify the host as an argument") } diff --git a/cmd/ssh_connect.go b/cmd/ssh_connect.go index 987033e..f9035ef 100644 --- a/cmd/ssh_connect.go +++ b/cmd/ssh_connect.go @@ -38,6 +38,10 @@ func makeSshConnect() *cobra.Command { } func runSshConnectE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("ssh connect"); err != nil { + return err + } + pat, err := getPat(cmd) if err != nil { return err diff --git a/cmd/ssh_ls.go b/cmd/ssh_ls.go index 087f898..7151dad 100644 --- a/cmd/ssh_ls.go +++ b/cmd/ssh_ls.go @@ -34,6 +34,9 @@ func makeSshList() *cobra.Command { } func runSshListE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("ssh list"); err != nil { + return err + } pat, err := getPat(cmd) if err != nil { diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 8c0a0d4..5cd5fd8 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -33,6 +33,9 @@ func makeUpgrade() *cobra.Command { } func runUpgradeE(cmd *cobra.Command, args []string) error { + if err := checkGitHubOnly("upgrade"); err != nil { + return err + } allHosts, err := cmd.Flags().GetBool("all") if err != nil {