Skip to content

Commit 160f204

Browse files
committed
implement CD commands
1 parent 58bb50e commit 160f204

15 files changed

Lines changed: 1310 additions & 150 deletions

pkg/bridge/services/actions.go

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
package services
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
gqlclient "github.com/pluralsh/console/go/client"
12+
"github.com/samber/lo"
13+
14+
"github.com/pluralsh/plural-cli/pkg/bridge"
15+
"github.com/pluralsh/plural-cli/pkg/utils"
16+
)
17+
18+
// CreateInput is the credential-free create payload.
19+
type CreateInput struct {
20+
ClusterID string
21+
Name string
22+
Namespace string
23+
RepoID string
24+
GitRef string
25+
GitFolder string
26+
Kustomize string
27+
Version string
28+
DryRun bool
29+
}
30+
31+
// UpdateInput is the credential-free update payload.
32+
type UpdateInput struct {
33+
ID string
34+
GitRef string
35+
GitFolder string
36+
Kustomize string
37+
Version string
38+
DryRun *bool
39+
}
40+
41+
// CloneInput is the credential-free clone payload.
42+
type CloneInput struct {
43+
SourceID string
44+
DestClusterID string
45+
Name string
46+
Namespace string
47+
}
48+
49+
// Loader is the narrow contract consumed by the Services screen.
50+
type Loader interface {
51+
ListClusters(ctx context.Context, query string) ([]Cluster, error)
52+
List(ctx context.Context, clusterID string, after *string, query string) (Page, error)
53+
Get(ctx context.Context, id string) (Detail, error)
54+
Kick(ctx context.Context, id string) (Detail, error)
55+
Delete(ctx context.Context, id string) error
56+
Create(ctx context.Context, input CreateInput) (Detail, error)
57+
Update(ctx context.Context, input UpdateInput) (Detail, error)
58+
Clone(ctx context.Context, input CloneInput) (Detail, error)
59+
DownloadTarball(ctx context.Context, id, dir string) (string, error)
60+
}
61+
62+
// API is the Console surface required by this package.
63+
type API interface {
64+
ListClusters() (*gqlclient.ListClusters, error)
65+
ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error)
66+
GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error)
67+
KickClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error)
68+
DeleteClusterService(serviceId string) (*gqlclient.DeleteServiceDeployment, error)
69+
CreateClusterService(clusterId, clusterName *string, attr gqlclient.ServiceDeploymentAttributes) (*gqlclient.ServiceDeploymentExtended, error)
70+
UpdateClusterService(serviceId, serviceName, clusterName *string, attributes gqlclient.ServiceUpdateAttributes) (*gqlclient.ServiceDeploymentExtended, error)
71+
CloneService(clusterId string, serviceId, serviceName, clusterName *string, attributes gqlclient.ServiceCloneAttributes) (*gqlclient.ServiceDeploymentFragment, error)
72+
GetDeployToken(clusterId, clusterName *string) (string, error)
73+
}
74+
75+
func (s *Service) Kick(ctx context.Context, id string) (Detail, error) {
76+
if err := ctx.Err(); err != nil {
77+
return Detail{}, err
78+
}
79+
id = strings.TrimSpace(id)
80+
if id == "" {
81+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID}
82+
}
83+
client, err := s.client(ctx)
84+
if err != nil {
85+
return Detail{}, err
86+
}
87+
service, err := client.KickClusterService(&id, nil, nil)
88+
if err != nil {
89+
return Detail{}, err
90+
}
91+
if service == nil {
92+
return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService}
93+
}
94+
return detailFromExtended(service), nil
95+
}
96+
97+
func (s *Service) Delete(ctx context.Context, id string) error {
98+
if err := ctx.Err(); err != nil {
99+
return err
100+
}
101+
id = strings.TrimSpace(id)
102+
if id == "" {
103+
return &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID}
104+
}
105+
client, err := s.client(ctx)
106+
if err != nil {
107+
return err
108+
}
109+
_, err = client.DeleteClusterService(id)
110+
return err
111+
}
112+
113+
func (s *Service) Create(ctx context.Context, input CreateInput) (Detail, error) {
114+
if err := ctx.Err(); err != nil {
115+
return Detail{}, err
116+
}
117+
input.ClusterID = strings.TrimSpace(input.ClusterID)
118+
input.Name = strings.TrimSpace(input.Name)
119+
input.RepoID = strings.TrimSpace(input.RepoID)
120+
input.GitRef = strings.TrimSpace(input.GitRef)
121+
input.GitFolder = strings.TrimSpace(input.GitFolder)
122+
if input.ClusterID == "" {
123+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster}
124+
}
125+
if input.Name == "" || input.RepoID == "" || input.GitRef == "" || input.GitFolder == "" {
126+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCreateFields}
127+
}
128+
if input.Namespace == "" {
129+
input.Namespace = "default"
130+
}
131+
if input.Version == "" {
132+
input.Version = "0.0.1"
133+
}
134+
client, err := s.client(ctx)
135+
if err != nil {
136+
return Detail{}, err
137+
}
138+
attrs := gqlclient.ServiceDeploymentAttributes{
139+
Name: input.Name,
140+
Namespace: input.Namespace,
141+
Version: lo.ToPtr(input.Version),
142+
RepositoryID: lo.ToPtr(input.RepoID),
143+
Git: &gqlclient.GitRefAttributes{Ref: input.GitRef, Folder: input.GitFolder},
144+
DryRun: lo.ToPtr(input.DryRun),
145+
}
146+
if input.Kustomize != "" {
147+
attrs.Kustomize = &gqlclient.KustomizeAttributes{Path: input.Kustomize}
148+
}
149+
service, err := client.CreateClusterService(&input.ClusterID, nil, attrs)
150+
if err != nil {
151+
return Detail{}, err
152+
}
153+
if service == nil {
154+
return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService}
155+
}
156+
return detailFromExtended(service), nil
157+
}
158+
159+
func (s *Service) Update(ctx context.Context, input UpdateInput) (Detail, error) {
160+
if err := ctx.Err(); err != nil {
161+
return Detail{}, err
162+
}
163+
input.ID = strings.TrimSpace(input.ID)
164+
if input.ID == "" {
165+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID}
166+
}
167+
client, err := s.client(ctx)
168+
if err != nil {
169+
return Detail{}, err
170+
}
171+
attrs := gqlclient.ServiceUpdateAttributes{}
172+
if input.GitRef != "" || input.GitFolder != "" {
173+
attrs.Git = &gqlclient.GitRefAttributes{Ref: input.GitRef, Folder: input.GitFolder}
174+
}
175+
if input.Version != "" {
176+
attrs.Version = lo.ToPtr(input.Version)
177+
}
178+
if input.DryRun != nil {
179+
attrs.DryRun = input.DryRun
180+
}
181+
if input.Kustomize != "" {
182+
attrs.Kustomize = &gqlclient.KustomizeAttributes{Path: input.Kustomize}
183+
}
184+
service, err := client.UpdateClusterService(&input.ID, nil, nil, attrs)
185+
if err != nil {
186+
return Detail{}, err
187+
}
188+
if service == nil {
189+
return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService}
190+
}
191+
return detailFromExtended(service), nil
192+
}
193+
194+
func (s *Service) Clone(ctx context.Context, input CloneInput) (Detail, error) {
195+
if err := ctx.Err(); err != nil {
196+
return Detail{}, err
197+
}
198+
input.SourceID = strings.TrimSpace(input.SourceID)
199+
input.DestClusterID = strings.TrimSpace(input.DestClusterID)
200+
input.Name = strings.TrimSpace(input.Name)
201+
if input.SourceID == "" {
202+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID}
203+
}
204+
if input.DestClusterID == "" {
205+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster}
206+
}
207+
if input.Name == "" {
208+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCreateFields}
209+
}
210+
if input.Namespace == "" {
211+
input.Namespace = "default"
212+
}
213+
client, err := s.client(ctx)
214+
if err != nil {
215+
return Detail{}, err
216+
}
217+
attrs := gqlclient.ServiceCloneAttributes{Name: input.Name, Namespace: lo.ToPtr(input.Namespace)}
218+
frag, err := client.CloneService(input.DestClusterID, &input.SourceID, nil, nil, attrs)
219+
if err != nil {
220+
return Detail{}, err
221+
}
222+
if frag == nil {
223+
return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService}
224+
}
225+
return s.Get(ctx, frag.ID)
226+
}
227+
228+
func (s *Service) DownloadTarball(ctx context.Context, id, dir string) (string, error) {
229+
if err := ctx.Err(); err != nil {
230+
return "", err
231+
}
232+
id = strings.TrimSpace(id)
233+
if id == "" {
234+
return "", &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID}
235+
}
236+
client, err := s.client(ctx)
237+
if err != nil {
238+
return "", err
239+
}
240+
service, err := client.GetClusterService(&id, nil, nil)
241+
if err != nil {
242+
return "", err
243+
}
244+
if service == nil {
245+
return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService}
246+
}
247+
if service.Tarball == nil || strings.TrimSpace(*service.Tarball) == "" {
248+
return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingTarball}
249+
}
250+
dir = strings.TrimSpace(dir)
251+
if dir == "" {
252+
dir = filepath.Join(".", service.Name+"-tarball")
253+
}
254+
if err := utils.EnsureEmptyDir(dir); err != nil {
255+
return "", err
256+
}
257+
if service.Cluster == nil {
258+
return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingCluster}
259+
}
260+
token, err := client.GetDeployToken(&service.Cluster.ID, nil)
261+
if err != nil {
262+
return "", err
263+
}
264+
resp, err := utils.ReadRemoteFileWithRetries(*service.Tarball, token, 3)
265+
if err != nil {
266+
return "", err
267+
}
268+
defer func(c io.Closer) { _ = c.Close() }(resp)
269+
if err := utils.Untar(dir, resp); err != nil {
270+
return "", err
271+
}
272+
abs, err := filepath.Abs(dir)
273+
if err != nil {
274+
return dir, nil
275+
}
276+
if _, err := os.Stat(abs); err != nil {
277+
return "", fmt.Errorf("tarball directory missing after unpack: %w", err)
278+
}
279+
return abs, nil
280+
}

pkg/bridge/services/errors.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package services
33
import "errors"
44

55
var (
6-
errNoConsole = errors.New("connect a Console profile before browsing Console resources")
7-
errMissingID = errors.New("service id is required")
8-
errMissingCluster = errors.New("cluster id is required")
9-
errMissingService = errors.New("service was not found")
6+
errNoConsole = errors.New("connect a Console profile before browsing Console resources")
7+
errMissingID = errors.New("service id is required")
8+
errMissingCluster = errors.New("cluster id is required")
9+
errMissingService = errors.New("service was not found")
10+
errMissingCreateFields = errors.New("name, repository id, git ref, and git folder are required")
11+
errMissingTarball = errors.New("service does not have a tarball")
1012
)

pkg/bridge/services/services.go

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Package services exposes read-only Console service list/get use cases to
1+
// Package services exposes Console service list/get/mutation use cases to
22
// presentation layers without importing TUI code.
33
package services
44

@@ -40,6 +40,7 @@ type ServiceError struct {
4040
// Detail is the credential-free detail payload for a service deployment.
4141
type Detail struct {
4242
Summary
43+
ClusterID string
4344
ClusterName string
4445
ClusterHandle string
4546
RevisionSHA string
@@ -57,25 +58,11 @@ type Page struct {
5758
TotalShown int
5859
}
5960

60-
// Loader is the narrow contract consumed by the Services screen.
61-
type Loader interface {
62-
ListClusters(ctx context.Context, query string) ([]Cluster, error)
63-
List(ctx context.Context, clusterID string, after *string, query string) (Page, error)
64-
Get(ctx context.Context, id string) (Detail, error)
65-
}
66-
6761
// ConsoleResolver supplies the active Console URL and token.
6862
type ConsoleResolver interface {
6963
ActiveConsole(ctx context.Context) (url, token string, err error)
7064
}
7165

72-
// API is the Console surface required by this package.
73-
type API interface {
74-
ListClusters() (*gqlclient.ListClusters, error)
75-
ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error)
76-
GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error)
77-
}
78-
7966
// ClientFactory builds a Console API for an authenticated endpoint.
8067
type ClientFactory func(token, url string) (API, error)
8168

@@ -254,6 +241,7 @@ func detailFromExtended(service *gqlclient.ServiceDeploymentExtended) Detail {
254241
detail.GitFolder = service.Git.Folder
255242
}
256243
if service.Cluster != nil {
244+
detail.ClusterID = service.Cluster.ID
257245
detail.ClusterName = service.Cluster.Name
258246
if service.Cluster.Handle != nil {
259247
detail.ClusterHandle = *service.Cluster.Handle

pkg/bridge/services/services_test.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) {
1919
}
2020

2121
type fakeAPI struct {
22-
clusters *gqlclient.ListClusters
23-
edges []*gqlclient.ServiceDeploymentEdgeFragment
24-
listErr error
25-
detail *gqlclient.ServiceDeploymentExtended
26-
getErr error
22+
clusters *gqlclient.ListClusters
23+
edges []*gqlclient.ServiceDeploymentEdgeFragment
24+
listErr error
25+
detail *gqlclient.ServiceDeploymentExtended
26+
getErr error
2727
clusterID string
2828
}
2929

@@ -39,6 +39,25 @@ func (f *fakeAPI) ListClusterServices(clusterId, _ *string) ([]*gqlclient.Servic
3939
func (f *fakeAPI) GetClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) {
4040
return f.detail, f.getErr
4141
}
42+
func (f *fakeAPI) KickClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) {
43+
return f.detail, f.getErr
44+
}
45+
func (f *fakeAPI) DeleteClusterService(string) (*gqlclient.DeleteServiceDeployment, error) {
46+
return &gqlclient.DeleteServiceDeployment{}, f.getErr
47+
}
48+
func (f *fakeAPI) CreateClusterService(*string, *string, gqlclient.ServiceDeploymentAttributes) (*gqlclient.ServiceDeploymentExtended, error) {
49+
return f.detail, f.getErr
50+
}
51+
func (f *fakeAPI) UpdateClusterService(*string, *string, *string, gqlclient.ServiceUpdateAttributes) (*gqlclient.ServiceDeploymentExtended, error) {
52+
return f.detail, f.getErr
53+
}
54+
func (f *fakeAPI) CloneService(string, *string, *string, *string, gqlclient.ServiceCloneAttributes) (*gqlclient.ServiceDeploymentFragment, error) {
55+
if f.detail == nil {
56+
return nil, f.getErr
57+
}
58+
return &gqlclient.ServiceDeploymentFragment{ID: f.detail.ID, Name: f.detail.Name, Namespace: f.detail.Namespace}, f.getErr
59+
}
60+
func (f *fakeAPI) GetDeployToken(*string, *string) (string, error) { return "token", f.getErr }
4261

4362
func TestListClustersAndScopedServices(t *testing.T) {
4463
handle := "prod-eu"
@@ -87,8 +106,8 @@ func TestGetMapsDetail(t *testing.T) {
87106
sha := "abc123"
88107
api := &fakeAPI{detail: &gqlclient.ServiceDeploymentExtended{
89108
ID: "svc-1", Name: "api", Namespace: "default", Status: gqlclient.ServiceDeploymentStatusFailed,
90-
Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"},
91-
Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle},
109+
Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"},
110+
Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle},
92111
Revision: &gqlclient.RevisionFragment{ID: "rev-1", Sha: &sha, Git: &gqlclient.RevisionFragment_Git{Ref: "main"}},
93112
Components: []*gqlclient.ServiceDeploymentExtended_Components{
94113
{Synced: true},

0 commit comments

Comments
 (0)