diff --git a/cmd/agent_logs.go b/cmd/agent_logs.go index b19ef00..a3d04c2 100644 --- a/cmd/agent_logs.go +++ b/cmd/agent_logs.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "strings" "time" @@ -36,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") } @@ -73,7 +76,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..d926ff7 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" @@ -28,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 { @@ -47,7 +49,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..cad571c 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" @@ -31,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") } @@ -59,7 +62,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..5b528ba 100644 --- a/cmd/increases.go +++ b/cmd/increases.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "strings" "time" @@ -28,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 { @@ -53,7 +55,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..a41e627 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "strings" "time" @@ -57,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) @@ -96,7 +99,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..3c8a4f2 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" @@ -35,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") } @@ -72,7 +75,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..05fef1c --- /dev/null +++ b/cmd/platform.go @@ -0,0 +1,61 @@ +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) + } +} + +// 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 4ba8ca6..1854cdf 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" @@ -34,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 { @@ -58,7 +60,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..e07c9db 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" @@ -34,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") } @@ -67,7 +70,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/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 4376444..5cd5fd8 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "strings" "time" @@ -34,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 { @@ -81,7 +83,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 +}