blob: e4dca4ce758508883dd5ce33b94f8fb74c455177 [file]
// Copyright 2022 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package tape enables access to the TAPE service which offers access to owned test accounts and
// configuration of policies on DPanel for those accounts.
// The TAPE project is documented here: TODO(alexanderhartl): add link once its finished
package tape
import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http"
"path/filepath"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"chromiumos/tast/errors"
)
// TokenDir is the location where the access token for tape is stored.
const TokenDir = "/tmp/tokensource"
// TokenFile is the file where the access token for tape is stored.
const TokenFile = "tokensource.json"
// MaxTimeout is the maximum timeout which is allowed when requesting a test account.
const MaxTimeout = 172800 * time.Second
// MaxGenericAccountTimeoutInSeconds is the maximum timeout which is allowed
// when requesting a generic account (2 hours).
const MaxGenericAccountTimeoutInSeconds = 60 * 60 * 2
const tapeURL = "https://tape-307412.ey.r.appspot.com/"
const tapeAudience = "770216225211-ihjn20dlehf94m9l4l5h0b0iilvd1vhc.apps.googleusercontent.com"
// Account is a struct representing an owned test account with its credentials.
type Account struct {
Email string `json:"email"`
GaiaID int64 `json:"gaiaid"`
Orgunit string `json:"orgunit"`
Password string `json:"password"`
Timeout int64 `json:"timeout"`
}
// GenericAccount stores information about a generic account in TAPE.
type GenericAccount struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
PoolID string `json:"pool_id"`
ReleaseTime float64 `json:"release_time"`
RequestID string `json:"request_id"`
}
// RequestGenericAccountParams holds the parameters for the
// request generic account endpoint.
type RequestGenericAccountParams struct {
TimeoutInSeconds int32 `json:"timeout"`
PoolID *string `json:"pool_id"`
}
// TokenString creates a json string from an oauth.Token generated for a serviceAccount
// which can be used for http connections to the TAPE GCP.
func TokenString(ctx context.Context, serviceAccountFile string) (string, error) {
ts, err := CreateTokenSource(ctx, serviceAccountFile)
if err != nil {
return "", errors.Wrap(err, "failed to create token")
}
token, err := ts.Token()
if err != nil {
return "", errors.Wrap(err, "failed to get Token")
}
tsJSON, err := json.Marshal(token)
if err != nil {
return "", errors.Wrap(err, "failed create JSON from token")
}
return string(tsJSON), nil
}
// CreateTokenSource an oauth2.TokenSource from a service account key file.
func CreateTokenSource(ctx context.Context, serviceAccountFile string) (oauth2.TokenSource, error) {
data, err := ioutil.ReadFile(serviceAccountFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read credential json")
}
config, err := google.JWTConfigFromJSON(data)
if err != nil {
return nil, errors.Wrap(err, "failed to generate JWT config")
}
customClaims := make(map[string]interface{})
customClaims["target_audience"] = tapeAudience
config.PrivateClaims = customClaims
config.UseIDToken = true
return config.TokenSource(ctx), nil
}
// NewTapeClient creates a http client which provides the necessary token to connect to the TAPE
// GCP from a service account key file. This function can only be called remotely as the DuT does
// not have service account key files. All functions in the tape package should be passed this http client.
func NewTapeClient(ctx context.Context, serviceAccountFile string) (*http.Client, error) {
ts, err := CreateTokenSource(ctx, serviceAccountFile)
if err != nil {
return nil, errors.Wrap(err, "failed to create token")
}
return oauth2.NewClient(ctx, ts), nil
}
// tapeTokensource is a Tokensource which returns an access token to authenticate against the TAPE GCP.
type tapeTokensource struct{ AccessToken *oauth2.Token }
func (ts tapeTokensource) Token() (*oauth2.Token, error) {
if ts.AccessToken == nil || ts.AccessToken.Expiry.Before(time.Now()) {
tokenJSON, err := ioutil.ReadFile(filepath.Join(TokenDir, TokenFile))
if err != nil {
return nil, errors.Wrap(err, "failed to read token file")
}
err = json.Unmarshal(tokenJSON, &ts.AccessToken)
if err != nil {
return nil, errors.Wrap(err, "failed to unmarshal the token")
}
if ts.AccessToken.Expiry.Before(time.Now()) {
return nil, errors.Wrap(err, "no valid token found")
}
}
return ts.AccessToken, nil
}
// NewTapeClientLocal creates a http client which provides the necessary token to connect to the TAPE
// GCP from a token string. This function can be used locally on a DuT and requires an oauth token to
// be present at TokenDir/TokenFile. All functions in the tape package should be passed this http client.
func NewTapeClientLocal(ctx context.Context) (*http.Client, error) {
ts := tapeTokensource{AccessToken: nil}
token, err := ts.Token()
if err != nil {
return nil, errors.Wrap(err, "failed to get access token")
}
return oauth2.NewClient(ctx, oauth2.ReuseTokenSource(token, ts)), nil
}
// sendRequestWithTimeout makes a call to the specified REST endpoint of TAPE with the given http method and payload.
func sendRequestWithTimeout(ctx context.Context, method, endpoint string, timeout time.Duration, payload *bytes.Reader, client *http.Client) (*http.Response, error) {
// Set the timeout of the client and return to the original after.
originalTimeout := client.Timeout
client.Timeout = timeout
defer func() {
client.Timeout = originalTimeout
}()
// Create a request.
req, err := http.NewRequestWithContext(ctx, method, tapeURL+endpoint, payload)
if err != nil {
return nil, errors.Wrap(err, "failed to create request")
}
req.Header.Set("Content-Type", "application/json")
// Send the request.
response, err := client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "failed to get a response from TAPE")
}
// Check if the call was successful.
if response.StatusCode != 200 {
return nil, errors.Errorf("%s at %s returned %d", method, endpoint, response.StatusCode)
}
return response, nil
}
// RequestAccount calls TAPE to obtain credentials for an available owned test account and returns it.
// The returned Account can not be obtained by other calls to RequestAccount until it is released or it times out
// after the given timeout is reached. A timeout of 0 will use the DEFAULT_ACCOUNT_TIMEOUT of the TAPE server
// which is 2 hours, timeouts larger than 2 days = 172800 seconds are not allowed.
// The returned password is temporary and will be valid for roughly one day.
func RequestAccount(ctx context.Context, timeout time.Duration, client *http.Client) (*Account, error) {
if timeout > MaxTimeout {
return nil, errors.Errorf("timeout may not be larger than %v seconds", MaxTimeout.Seconds())
}
payloadBytes, err := json.Marshal(timeout.Seconds())
if err != nil {
return nil, errors.Wrap(err, "failed to marshal data")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "requestAccount", 30*time.Second, payload, client)
if err != nil {
return nil, errors.Wrap(err, "failed to make REST call")
}
defer response.Body.Close()
// Read the response.
respBody, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, errors.Wrap(err, "failed to read response from TAPE")
}
var acc Account
err = json.Unmarshal([]byte(respBody), &acc)
if err != nil {
return nil, err
}
return &acc, nil
}
// RegeneratePassword calls TAPE to obtain a new temporary Password for the given Account and returns the Account.
// A temporary password will be valid for roughly a day. Tests are not expected to use this as RequestAccount will
// already provide a temporary password.
func (acc *Account) RegeneratePassword(ctx context.Context, client *http.Client) error {
payloadBytes, err := json.Marshal(acc)
if err != nil {
return errors.Wrap(err, "failed to marshal data")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "regeneratePassword", 30*time.Second, payload, client)
if err != nil {
return errors.Wrap(err, "failed to make REST call")
}
defer response.Body.Close()
// Read the response.
respBody, err := ioutil.ReadAll(response.Body)
if err != nil {
return errors.Wrap(err, "failed to read response from TAPE")
}
var password string
err = json.Unmarshal([]byte(respBody), &password)
if err != nil {
return err
}
acc.Password = password
return nil
}
// CleanUp calls TAPE to clean up the Account. All policies will be set to their default values and all other
// state changes will also be reverted. However the account will not be released with this call.
// Accounts will always be cleaned up when they are requested with RequestAccount, tests are not expected to call
// this function to clean up when a test is finished.
func (acc *Account) CleanUp(ctx context.Context, client *http.Client) error {
payloadBytes, err := json.Marshal(acc)
if err != nil {
return errors.Wrap(err, "failed to convert Account to json")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "cleanUp", 60*time.Second, payload, client)
if err != nil {
return errors.Wrap(err, "failed to make REST call")
}
defer response.Body.Close()
// Read the response.
_, err = ioutil.ReadAll(response.Body)
if err != nil {
return errors.Wrap(err, "failed to read response from TAPE")
}
return nil
}
// ReleaseAccount calls TAPE to release the Account so it becomes available again.
func (acc *Account) ReleaseAccount(ctx context.Context, client *http.Client) error {
payloadBytes, err := json.Marshal(acc)
if err != nil {
return errors.Wrap(err, "failed to convert Account to json")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "releaseAccount", 30*time.Second, payload, client)
if err != nil {
return errors.Wrap(err, "failed to make REST call")
}
defer response.Body.Close()
// Read the response.
_, err = ioutil.ReadAll(response.Body)
if err != nil {
return errors.Wrap(err, "failed to read response from TAPE")
}
return nil
}
// SetPolicy calls TAPE to set policySchema in DPanel.
func (acc *Account) SetPolicy(ctx context.Context, policySchema PolicySchema, client *http.Client) error {
payloadBytes, err := policySchema.Schema2JSON(acc.Orgunit)
if err != nil {
return errors.Wrap(err, "failed to marshal data")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "setPolicy", 30*time.Second, payload, client)
if err != nil {
return errors.Wrap(err, "failed to make REST call")
}
defer response.Body.Close()
return nil
}
// DeprovisionRequest is a struct containing the necessary data to deprovision a device.
type DeprovisionRequest struct {
DeviceID string `json:"deviceid"`
CustomerID string `json:"customerid"`
}
// Deprovision calls TAPE to deprovision a device in DPanel.
func Deprovision(ctx context.Context, request DeprovisionRequest, client *http.Client) error {
payloadBytes, err := json.Marshal(request)
if err != nil {
return errors.Wrap(err, "failed to marshal data")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "deprovision", 30*time.Second, payload, client)
if err != nil {
return errors.Wrap(err, "failed to make REST call")
}
defer response.Body.Close()
return nil
}
// RequestGenericAccount sends a request for leasing a generic account.
func RequestGenericAccount(ctx context.Context, params RequestGenericAccountParams, client *http.Client) (*GenericAccount, error) {
// Validate the provided parameters.
if int64(params.TimeoutInSeconds) > int64(MaxGenericAccountTimeoutInSeconds) {
return nil, errors.Errorf("Timeout may not be larger than %v seconds", MaxGenericAccountTimeoutInSeconds)
}
if params.PoolID != nil && len(*params.PoolID) <= 0 {
return nil, errors.New("PoolID must not be empty when set")
}
// Make the request
payloadBytes, err := json.Marshal(params)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal data")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "GenericAccount/request", 30*time.Second, payload, client)
if err != nil {
return nil, errors.Wrap(err, "failed to make request")
}
defer response.Body.Close()
// Read the response.
respBody, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, errors.Wrap(err, "failed to read response")
}
var acc GenericAccount
if err := json.Unmarshal([]byte(respBody), &acc); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal response")
}
return &acc, nil
}
// ReleaseGenericAccount sends a request for releasing a leased account.
func ReleaseGenericAccount(ctx context.Context, account *GenericAccount, client *http.Client) error {
if account == nil {
return errors.New("account is not set")
}
if client == nil {
return errors.New("client is not set")
}
// Make the request
payloadBytes, err := json.Marshal(account)
if err != nil {
return errors.Wrap(err, "failed to marshal data")
}
payload := bytes.NewReader(payloadBytes)
response, err := sendRequestWithTimeout(ctx, "POST", "GenericAccount/release", 30*time.Second, payload, client)
if err != nil {
return errors.Wrap(err, "failed to make request")
}
defer response.Body.Close()
// Make sure the request was successful.
if response.StatusCode != 200 {
return errors.Errorf("failed to release account, status code: %d", response.StatusCode)
}
return nil
}