2023-01-17 11:26:25 +01:00

386 lines
12 KiB
Go

// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.
package apiv1
import (
"context"
"fmt"
connect "github.com/bufbuild/connect-go"
"github.com/gitpod-io/gitpod/common-go/log"
v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"
protocol "github.com/gitpod-io/gitpod/gitpod-protocol"
"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus"
"google.golang.org/protobuf/types/known/timestamppb"
)
func NewWorkspaceService(serverConnPool proxy.ServerConnectionPool) *WorkspaceService {
return &WorkspaceService{
connectionPool: serverConnPool,
}
}
type WorkspaceService struct {
connectionPool proxy.ServerConnectionPool
v1connect.UnimplementedWorkspacesServiceHandler
}
func (s *WorkspaceService) GetWorkspace(ctx context.Context, req *connect.Request[v1.GetWorkspaceRequest]) (*connect.Response[v1.GetWorkspaceResponse], error) {
workspaceID, err := validateWorkspaceID(req.Msg.GetWorkspaceId())
if err != nil {
return nil, err
}
logger := ctxlogrus.Extract(ctx).WithField("workspace_id", workspaceID)
conn, err := getConnection(ctx, s.connectionPool)
if err != nil {
return nil, err
}
workspace, err := conn.GetWorkspace(ctx, workspaceID)
if err != nil {
logger.WithError(err).Error("Failed to get workspace.")
return nil, proxy.ConvertError(err)
}
instance, err := convertWorkspaceInstance(workspace.LatestInstance, workspace.Workspace.Shareable)
if err != nil {
logger.WithError(err).Error("Failed to convert workspace instance.")
instance = &v1.WorkspaceInstance{}
}
return connect.NewResponse(&v1.GetWorkspaceResponse{
Result: &v1.Workspace{
WorkspaceId: workspace.Workspace.ID,
OwnerId: workspace.Workspace.OwnerID,
ProjectId: "",
Context: &v1.WorkspaceContext{
ContextUrl: workspace.Workspace.ContextURL,
Details: &v1.WorkspaceContext_Git_{Git: &v1.WorkspaceContext_Git{
NormalizedContextUrl: workspace.Workspace.ContextURL,
Commit: "",
}},
},
Description: workspace.Workspace.Description,
Status: &v1.WorkspaceStatus{
Instance: instance,
},
},
}), nil
}
func (s *WorkspaceService) StreamWorkspaceStatus(ctx context.Context, req *connect.Request[v1.StreamWorkspaceStatusRequest], stream *connect.ServerStream[v1.StreamWorkspaceStatusResponse]) error {
workspaceID, err := validateWorkspaceID(req.Msg.GetWorkspaceId())
if err != nil {
return err
}
logger := ctxlogrus.Extract(ctx).WithField("workspace_id", workspaceID)
conn, err := getConnection(ctx, s.connectionPool)
if err != nil {
return err
}
workspace, err := conn.GetWorkspace(ctx, workspaceID)
if err != nil {
logger.WithError(err).Error("Failed to get workspace.")
return proxy.ConvertError(err)
}
if workspace.LatestInstance == nil {
logger.WithError(err).Error("Failed to get latest instance.")
return connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("instance not found"))
}
ch, err := conn.InstanceUpdates(ctx, workspace.LatestInstance.ID)
if err != nil {
logger.WithError(err).Error("Failed to get workspace instance updates.")
return proxy.ConvertError(err)
}
for update := range ch {
instance, err := convertWorkspaceInstance(update, workspace.Workspace.Shareable)
if err != nil {
logger.WithError(err).Error("Failed to convert workspace instance.")
return proxy.ConvertError(err)
}
err = stream.Send(&v1.StreamWorkspaceStatusResponse{
Result: &v1.WorkspaceStatus{
Instance: instance,
},
})
if err != nil {
logger.WithError(err).Error("Failed to stream workspace status.")
return proxy.ConvertError(err)
}
}
return nil
}
func (s *WorkspaceService) GetOwnerToken(ctx context.Context, req *connect.Request[v1.GetOwnerTokenRequest]) (*connect.Response[v1.GetOwnerTokenResponse], error) {
workspaceID, err := validateWorkspaceID(req.Msg.GetWorkspaceId())
if err != nil {
return nil, err
}
logger := ctxlogrus.Extract(ctx).WithField("workspace_id", workspaceID)
conn, err := getConnection(ctx, s.connectionPool)
if err != nil {
return nil, err
}
ownerToken, err := conn.GetOwnerToken(ctx, workspaceID)
if err != nil {
logger.WithError(err).Error("Failed to get owner token.")
return nil, proxy.ConvertError(err)
}
return connect.NewResponse(&v1.GetOwnerTokenResponse{Token: ownerToken}), nil
}
func (s *WorkspaceService) ListWorkspaces(ctx context.Context, req *connect.Request[v1.ListWorkspacesRequest]) (*connect.Response[v1.ListWorkspacesResponse], error) {
conn, err := getConnection(ctx, s.connectionPool)
if err != nil {
return nil, err
}
limit, err := getLimitFromPagination(req.Msg.GetPagination())
if err != nil {
// getLimitFromPagination returns gRPC errors
return nil, err
}
serverResp, err := conn.GetWorkspaces(ctx, &protocol.GetWorkspacesOptions{
Limit: float64(limit),
})
if err != nil {
return nil, proxy.ConvertError(err)
}
res := make([]*v1.Workspace, 0, len(serverResp))
for _, ws := range serverResp {
workspace, err := convertWorkspaceInfo(ws)
if err != nil {
// convertWorkspaceInfo returns gRPC errors
return nil, err
}
res = append(res, workspace)
}
return connect.NewResponse(
&v1.ListWorkspacesResponse{
Result: res,
},
), nil
}
func (s *WorkspaceService) UpdatePort(ctx context.Context, req *connect.Request[v1.UpdatePortRequest]) (*connect.Response[v1.UpdatePortResponse], error) {
workspaceID, err := validateWorkspaceID(req.Msg.GetWorkspaceId())
if err != nil {
return nil, err
}
conn, err := getConnection(ctx, s.connectionPool)
if err != nil {
return nil, err
}
switch req.Msg.GetPort().GetPolicy() {
case v1.PortPolicy_PORT_POLICY_PRIVATE:
_, err = conn.OpenPort(ctx, workspaceID, &protocol.WorkspaceInstancePort{
Port: float64(req.Msg.Port.Port),
Visibility: protocol.PortVisibilityPrivate,
})
case v1.PortPolicy_PORT_POLICY_PUBLIC:
_, err = conn.OpenPort(ctx, workspaceID, &protocol.WorkspaceInstancePort{
Port: float64(req.Msg.Port.Port),
Visibility: protocol.PortVisibilityPublic,
})
default:
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Unknown port policy specified."))
}
if err != nil {
log.WithField("workspace_id", workspaceID).Error("Failed to update port")
return nil, proxy.ConvertError(err)
}
return connect.NewResponse(
&v1.UpdatePortResponse{},
), nil
}
func (s *WorkspaceService) StopWorkspace(ctx context.Context, req *connect.Request[v1.StopWorkspaceRequest]) (*connect.Response[v1.StopWorkspaceResponse], error) {
workspaceID, err := validateWorkspaceID(req.Msg.GetWorkspaceId())
if err != nil {
return nil, err
}
conn, err := getConnection(ctx, s.connectionPool)
if err != nil {
return nil, err
}
err = conn.StopWorkspace(ctx, workspaceID)
if err != nil {
log.WithField("workspace_id", workspaceID).WithError(err).Error("Failed to stop workspace.")
return nil, proxy.ConvertError(err)
}
return connect.NewResponse(&v1.StopWorkspaceResponse{}), nil
}
func (s *WorkspaceService) DeleteWorkspace(ctx context.Context, req *connect.Request[v1.DeleteWorkspaceRequest]) (*connect.Response[v1.DeleteWorkspaceResponse], error) {
workspaceID, err := validateWorkspaceID(req.Msg.GetWorkspaceId())
if err != nil {
return nil, err
}
conn, err := getConnection(ctx, s.connectionPool)
if err != nil {
return nil, err
}
err = conn.DeleteWorkspace(ctx, workspaceID)
if err != nil {
log.WithField("workspace_id", workspaceID).WithError(err).Error("Failed to delete workspace.")
return nil, proxy.ConvertError(err)
}
return connect.NewResponse(&v1.DeleteWorkspaceResponse{}), nil
}
func getLimitFromPagination(pagination *v1.Pagination) (int, error) {
const (
defaultLimit = 20
maxLimit = 100
)
if pagination == nil {
return defaultLimit, nil
}
if pagination.PageSize == 0 {
return defaultLimit, nil
}
if pagination.PageSize < 0 || maxLimit < pagination.PageSize {
return 0, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid pagination page size (must be 0 < x < %d)", maxLimit))
}
return int(pagination.PageSize), nil
}
// convertWorkspaceInfo convers a "protocol workspace" to a "public API workspace". Returns gRPC errors if things go wrong.
func convertWorkspaceInfo(input *protocol.WorkspaceInfo) (*v1.Workspace, error) {
instance, err := convertWorkspaceInstance(input.LatestInstance, input.Workspace.Shareable)
if err != nil {
return nil, err
}
return &v1.Workspace{
WorkspaceId: input.Workspace.ID,
OwnerId: input.Workspace.OwnerID,
ProjectId: "",
Context: &v1.WorkspaceContext{
ContextUrl: input.Workspace.ContextURL,
Details: &v1.WorkspaceContext_Git_{Git: &v1.WorkspaceContext_Git{
NormalizedContextUrl: input.Workspace.ContextURL,
Commit: "",
}},
},
Description: input.Workspace.Description,
Status: &v1.WorkspaceStatus{
Instance: instance,
},
}, nil
}
func convertWorkspaceInstance(wsi *protocol.WorkspaceInstance, shareable bool) (*v1.WorkspaceInstance, error) {
if wsi == nil {
return nil, nil
}
creationTime, err := parseGitpodTimestamp(wsi.CreationTime)
if err != nil {
// TODO(cw): should this really return an error and possibly fail the entire operation?
return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("cannot parse creation time: %v", err))
}
var phase v1.WorkspaceInstanceStatus_Phase
switch wsi.Status.Phase {
case "unknown":
phase = v1.WorkspaceInstanceStatus_PHASE_UNSPECIFIED
case "preparing":
phase = v1.WorkspaceInstanceStatus_PHASE_PREPARING
case "building":
phase = v1.WorkspaceInstanceStatus_PHASE_IMAGEBUILD
case "pending":
phase = v1.WorkspaceInstanceStatus_PHASE_PENDING
case "creating":
phase = v1.WorkspaceInstanceStatus_PHASE_CREATING
case "initializing":
phase = v1.WorkspaceInstanceStatus_PHASE_INITIALIZING
case "running":
phase = v1.WorkspaceInstanceStatus_PHASE_RUNNING
case "interrupted":
phase = v1.WorkspaceInstanceStatus_PHASE_INTERRUPTED
case "stopping":
phase = v1.WorkspaceInstanceStatus_PHASE_STOPPING
case "stopped":
phase = v1.WorkspaceInstanceStatus_PHASE_STOPPED
default:
// TODO(cw): should this really return an error and possibly fail the entire operation?
return nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("cannot convert instance phase: %s", wsi.Status.Phase))
}
var admissionLevel v1.AdmissionLevel
if shareable {
admissionLevel = v1.AdmissionLevel_ADMISSION_LEVEL_EVERYONE
} else {
admissionLevel = v1.AdmissionLevel_ADMISSION_LEVEL_OWNER_ONLY
}
var firstUserActivity *timestamppb.Timestamp
if fua := wsi.Status.Conditions.FirstUserActivity; fua != "" {
firstUserActivity, _ = parseGitpodTimestamp(fua)
}
var ports []*v1.Port
for _, p := range wsi.Status.ExposedPorts {
port := &v1.Port{
Port: uint64(p.Port),
Url: p.URL,
}
if p.Visibility == protocol.PortVisibilityPublic {
port.Policy = v1.PortPolicy_PORT_POLICY_PUBLIC
} else {
port.Policy = v1.PortPolicy_PORT_POLICY_PRIVATE
}
ports = append(ports, port)
}
return &v1.WorkspaceInstance{
InstanceId: wsi.ID,
WorkspaceId: wsi.WorkspaceID,
CreatedAt: creationTime,
Status: &v1.WorkspaceInstanceStatus{
StatusVersion: uint64(wsi.Status.Version),
Phase: phase,
Message: wsi.Status.Message,
Url: wsi.IdeURL,
Admission: admissionLevel,
Conditions: &v1.WorkspaceInstanceStatus_Conditions{
Failed: wsi.Status.Conditions.Failed,
Timeout: wsi.Status.Conditions.Timeout,
FirstUserActivity: firstUserActivity,
},
Ports: ports,
},
}, nil
}