Skip to content

Commit 3e6f584

Browse files
irainiaarinda-arif
andauthored
feat: yaml export for job and resource (#717)
* refactor: clean up create command for export command * feat: enhance GetJobSpecifications to support namespace name filter * feat: add resource export sub-command * feat: enhance GetJobSpecifications to support include deleted jobs filter * Revert "feat: enhance GetJobSpecifications to support include deleted jobs filter" This reverts commit 337c53c. * refactor: remove include deleted filter from GetJobSpecifications request * feat: add job export sub-command * chore: fix lint issues * fix: nil writer issue in job export and add successful case log * fix: convert job alert config type to the proper value * refactor: add logging activity on resource export Co-authored-by: Arinda Arif <[email protected]> * fix: read config automatically if not being set Co-authored-by: Arinda Arif <[email protected]> * refactor: improve logs for job export * test: add job_spec test in client side * refactor: improve logging on job and resource export Co-authored-by: Arinda Arif <[email protected]>
1 parent f32d70c commit 3e6f584

File tree

15 files changed

+1093
-196
lines changed

15 files changed

+1093
-196
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ NAME = "github.com/odpf/optimus"
55
LAST_COMMIT := $(shell git rev-parse --short HEAD)
66
LAST_TAG := "$(shell git rev-list --tags --max-count=1)"
77
OPMS_VERSION := "$(shell git describe --tags ${LAST_TAG})-next"
8-
PROTON_COMMIT := "90b5d53e3e58e017032d12275597b93f53263add"
8+
PROTON_COMMIT := "e75e288ec2a42ecd3358b2a12ddcd8bea50a4a07"
99

1010
.PHONY: build test test-ci generate-proto unit-test-ci integration-test vet coverage clean install lint
1111

api/handler/v1beta1/job_spec.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ func (sv *JobSpecServiceServer) GetJobSpecifications(ctx context.Context, req *p
398398
ProjectName: req.GetProjectName(),
399399
JobName: req.GetJobName(),
400400
ResourceDestination: req.GetResourceDestination(),
401+
NamespaceName: req.GetNamespaceName(),
401402
}
402403
jobSpecs, err := sv.jobSvc.GetByFilter(ctx, jobSpecFilter)
403404
if err != nil {

api/handler/v1beta1/job_spec_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,8 @@ func (s *JobSpecServiceServerTestSuite) TestGetJobSpecification_Fail_JobServiceG
375375
}
376376

377377
func (s *JobSpecServiceServerTestSuite) TestGetJobSpecifications_Success() {
378-
req := &pb.GetJobSpecificationsRequest{JobName: "job-1"}
379-
jobSpecFilter := models.JobSpecFilter{JobName: req.GetJobName()}
378+
req := &pb.GetJobSpecificationsRequest{JobName: "job-1", NamespaceName: "namespace-1"}
379+
jobSpecFilter := models.JobSpecFilter{JobName: req.GetJobName(), NamespaceName: req.GetNamespaceName()}
380380

381381
execUnit1 := new(mock.YamlMod)
382382
execUnit1.On("PluginInfo").Return(&models.PluginInfoResponse{Name: "task"}, nil)

client/cmd/job/export.go

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package job
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"path"
7+
"strings"
8+
"time"
9+
10+
"github.com/odpf/salt/log"
11+
"github.com/spf13/afero"
12+
"github.com/spf13/cobra"
13+
14+
"github.com/odpf/optimus/client/cmd/internal/connectivity"
15+
"github.com/odpf/optimus/client/cmd/internal/logger"
16+
"github.com/odpf/optimus/client/local"
17+
"github.com/odpf/optimus/client/local/model"
18+
"github.com/odpf/optimus/client/local/specio"
19+
"github.com/odpf/optimus/config"
20+
pb "github.com/odpf/optimus/protos/odpf/optimus/core/v1beta1"
21+
)
22+
23+
const (
24+
fetchTenantTimeout = time.Minute
25+
fetchJobTimeout = time.Minute * 15
26+
)
27+
28+
type exportCommand struct {
29+
logger log.Logger
30+
writer local.SpecWriter[*model.JobSpec]
31+
32+
configFilePath string
33+
outputDirPath string
34+
host string
35+
36+
projectName string
37+
namespaceName string
38+
jobName string
39+
}
40+
41+
// NewExportCommand initializes command for exporting job specification to yaml file
42+
func NewExportCommand() *cobra.Command {
43+
export := &exportCommand{
44+
logger: logger.NewClientLogger(),
45+
}
46+
47+
cmd := &cobra.Command{
48+
Use: "export",
49+
Short: "Export job specifications to YAML files",
50+
Example: "optimus job export",
51+
RunE: export.RunE,
52+
PreRunE: export.PreRunE,
53+
}
54+
55+
cmd.Flags().StringVarP(&export.configFilePath, "config", "c", export.configFilePath, "File path for client configuration")
56+
cmd.Flags().StringVar(&export.outputDirPath, "dir", "", "Output directory path")
57+
cmd.Flags().StringVar(&export.host, "host", "", "Host of the server source (will override value from config)")
58+
59+
cmd.Flags().StringVarP(&export.projectName, "project-name", "p", "", "Project name target")
60+
cmd.Flags().StringVarP(&export.namespaceName, "namespace-name", "n", "", "Namespace name target within the selected project name")
61+
cmd.Flags().StringVarP(&export.jobName, "job-name", "r", "", "Job name target")
62+
63+
cmd.MarkFlagRequired("dir")
64+
return cmd
65+
}
66+
67+
func (e *exportCommand) PreRunE(_ *cobra.Command, _ []string) error {
68+
readWriter, err := specio.NewJobSpecReadWriter(afero.NewOsFs())
69+
if err != nil {
70+
e.logger.Error(err.Error())
71+
}
72+
e.writer = readWriter
73+
74+
if e.host != "" {
75+
return nil
76+
}
77+
78+
if e.configFilePath != "" {
79+
e.logger.Info("Loading client config from %s", e.configFilePath)
80+
}
81+
cfg, err := config.LoadClientConfig(e.configFilePath)
82+
if err != nil {
83+
e.logger.Warn("error is encountered when reading config file: %s", err)
84+
} else {
85+
e.host = cfg.Host
86+
}
87+
return err
88+
}
89+
90+
func (e *exportCommand) RunE(_ *cobra.Command, _ []string) error {
91+
e.logger.Info("Validating input")
92+
if err := e.validate(); err != nil {
93+
return err
94+
}
95+
96+
var success bool
97+
if e.projectName != "" && e.namespaceName != "" && e.jobName != "" {
98+
e.logger.Info("Downloading job [%s] from project [%s] namespace [%s]", e.jobName, e.projectName, e.namespaceName)
99+
success = e.downloadSpecificJob(e.projectName, e.namespaceName, e.jobName)
100+
} else if e.projectName != "" && e.namespaceName != "" {
101+
e.logger.Info("Downloading all jobs within project [%s] namespace [%s]", e.projectName, e.namespaceName)
102+
success = e.downloadByProjectNameAndNamespaceName(e.projectName, e.namespaceName)
103+
} else if e.projectName != "" {
104+
e.logger.Info("Downloading all jobs within project [%s]", e.projectName)
105+
success = e.downloadByProjectName(e.projectName)
106+
} else {
107+
e.logger.Info("Downloading all jobs")
108+
success = e.downloadAll()
109+
}
110+
111+
if !success {
112+
e.logger.Error("Download process failed")
113+
return errors.New("encountered one or more errors during download jobs")
114+
}
115+
e.logger.Info("Download process success")
116+
return nil
117+
}
118+
119+
func (e *exportCommand) downloadAll() bool {
120+
e.logger.Info("Fetching all project names")
121+
projectNames, err := e.fetchProjectNames()
122+
if err != nil {
123+
e.logger.Error("error is encountered when fetching project names: %s", err)
124+
return false
125+
}
126+
if len(projectNames) == 0 {
127+
e.logger.Warn("no project is found from the specified host")
128+
return true
129+
}
130+
131+
success := true
132+
for _, pName := range projectNames {
133+
if !e.downloadByProjectName(pName) {
134+
success = false
135+
}
136+
}
137+
return success
138+
}
139+
140+
func (e *exportCommand) downloadByProjectName(projectName string) bool {
141+
e.logger.Info("Fetching all jobs for project [%s]", projectName)
142+
namespaceJobs, err := e.fetchNamespaceJobsByProjectName(projectName)
143+
if err != nil {
144+
e.logger.Error("error is encountered when fetching job specs for project [%s]: %s", projectName, err)
145+
return false
146+
}
147+
148+
success := true
149+
for namespaceName, jobSpecs := range namespaceJobs {
150+
if len(jobSpecs) == 0 {
151+
e.logger.Warn("No jobs found for project [%s] namespace [%s]", projectName, namespaceName)
152+
continue
153+
}
154+
if err := e.writeJobs(projectName, namespaceName, jobSpecs); err != nil {
155+
e.logger.Error(err.Error())
156+
success = false
157+
}
158+
}
159+
return success
160+
}
161+
162+
func (e *exportCommand) downloadByProjectNameAndNamespaceName(projectName, namespaceName string) bool {
163+
e.logger.Info("Fetching all jobs for project [%s] namespace [%s]", projectName, namespaceName)
164+
jobs, err := e.fetchJobsByProjectAndNamespaceName(projectName, namespaceName)
165+
if err != nil {
166+
e.logger.Error("error is encountered when fetching job specs for project [%s]: %s", projectName, err)
167+
return false
168+
}
169+
if len(jobs) == 0 {
170+
e.logger.Warn("No jobs found for project [%s] namespace [%s]", projectName, namespaceName)
171+
return true
172+
}
173+
if err := e.writeJobs(projectName, namespaceName, jobs); err != nil {
174+
e.logger.Error(err.Error())
175+
return false
176+
}
177+
return true
178+
}
179+
180+
func (e *exportCommand) downloadSpecificJob(projectName, namespaceName, jobName string) bool {
181+
e.logger.Info("Fetching job [%s] from project [%s] namespace [%s]", jobName, projectName, namespaceName)
182+
job, err := e.fetchSpecificJob(projectName, namespaceName, jobName)
183+
if err != nil {
184+
e.logger.Error("error is encountered when fetching job specs for project [%s]: %s", projectName, err)
185+
return false
186+
}
187+
188+
if err := e.writeJobs(projectName, namespaceName, []*model.JobSpec{job}); err != nil {
189+
e.logger.Error(err.Error())
190+
return false
191+
}
192+
return true
193+
}
194+
195+
func (e *exportCommand) writeJobs(projectName, namespaceName string, jobs []*model.JobSpec) error {
196+
e.logger.Info("Writing %d jobs for project [%s] namespace [%s]", len(jobs), projectName, namespaceName)
197+
198+
var errMsgs []string
199+
for _, spec := range jobs {
200+
dirPath := path.Join(e.outputDirPath, projectName, namespaceName, "jobs", spec.Name)
201+
202+
e.logger.Info("Writing job to [%s]", dirPath)
203+
if err := e.writer.Write(dirPath, spec); err != nil {
204+
errMsgs = append(errMsgs, err.Error())
205+
}
206+
}
207+
if len(errMsgs) > 0 {
208+
return fmt.Errorf("encountered one or more errors when writing jobs:\n%s", strings.Join(errMsgs, "\n"))
209+
}
210+
return nil
211+
}
212+
213+
func (e *exportCommand) fetchNamespaceJobsByProjectName(projectName string) (map[string][]*model.JobSpec, error) {
214+
conn, err := connectivity.NewConnectivity(e.host, fetchJobTimeout)
215+
if err != nil {
216+
return nil, err
217+
}
218+
defer conn.Close()
219+
220+
jobSpecificationServiceClient := pb.NewJobSpecificationServiceClient(conn.GetConnection())
221+
222+
response, err := jobSpecificationServiceClient.GetJobSpecifications(conn.GetContext(), &pb.GetJobSpecificationsRequest{
223+
ProjectName: projectName,
224+
})
225+
if err != nil {
226+
return nil, err
227+
}
228+
229+
namespaceJobsMap := make(map[string][]*model.JobSpec)
230+
for _, jobProto := range response.JobSpecificationResponses {
231+
namespaceJobsMap[jobProto.GetNamespaceName()] = append(namespaceJobsMap[jobProto.GetNamespaceName()], model.ToJobSpec(jobProto.Job))
232+
}
233+
return namespaceJobsMap, nil
234+
}
235+
236+
func (e *exportCommand) fetchJobsByProjectAndNamespaceName(projectName, namespaceName string) ([]*model.JobSpec, error) {
237+
conn, err := connectivity.NewConnectivity(e.host, fetchJobTimeout)
238+
if err != nil {
239+
return nil, err
240+
}
241+
defer conn.Close()
242+
243+
jobSpecificationServiceClient := pb.NewJobSpecificationServiceClient(conn.GetConnection())
244+
245+
response, err := jobSpecificationServiceClient.GetJobSpecifications(conn.GetContext(), &pb.GetJobSpecificationsRequest{
246+
ProjectName: projectName,
247+
NamespaceName: namespaceName,
248+
})
249+
if err != nil {
250+
return nil, err
251+
}
252+
253+
jobs := make([]*model.JobSpec, len(response.JobSpecificationResponses))
254+
for i, jobProto := range response.JobSpecificationResponses {
255+
jobs[i] = model.ToJobSpec(jobProto.Job)
256+
}
257+
return jobs, nil
258+
}
259+
260+
func (e *exportCommand) fetchSpecificJob(projectName, namespaceName, jobName string) (*model.JobSpec, error) {
261+
conn, err := connectivity.NewConnectivity(e.host, fetchJobTimeout)
262+
if err != nil {
263+
return nil, err
264+
}
265+
defer conn.Close()
266+
267+
jobSpecificationServiceClient := pb.NewJobSpecificationServiceClient(conn.GetConnection())
268+
269+
response, err := jobSpecificationServiceClient.GetJobSpecifications(conn.GetContext(), &pb.GetJobSpecificationsRequest{
270+
ProjectName: projectName,
271+
NamespaceName: namespaceName,
272+
JobName: jobName,
273+
})
274+
if err != nil {
275+
return nil, err
276+
}
277+
278+
if len(response.JobSpecificationResponses) == 0 {
279+
return nil, errors.New("job is not found")
280+
}
281+
return model.ToJobSpec(response.JobSpecificationResponses[0].Job), nil
282+
}
283+
284+
func (e *exportCommand) fetchProjectNames() ([]string, error) {
285+
conn, err := connectivity.NewConnectivity(e.host, fetchTenantTimeout)
286+
if err != nil {
287+
return nil, err
288+
}
289+
defer conn.Close()
290+
291+
projectServiceClient := pb.NewProjectServiceClient(conn.GetConnection())
292+
293+
response, err := projectServiceClient.ListProjects(conn.GetContext(), &pb.ListProjectsRequest{})
294+
if err != nil {
295+
return nil, err
296+
}
297+
298+
output := make([]string, len(response.Projects))
299+
for i, p := range response.Projects {
300+
output[i] = p.GetName()
301+
}
302+
return output, nil
303+
}
304+
305+
func (e *exportCommand) validate() error {
306+
if e.host == "" {
307+
return errors.New("host is not specified in both config file and flag argument")
308+
}
309+
if e.namespaceName != "" && e.projectName == "" {
310+
return errors.New("project name has to be specified since namespace name is specified")
311+
}
312+
if e.jobName != "" && (e.projectName == "" || e.namespaceName == "") {
313+
return errors.New("project name and namespace name have to be specified since job name is specified")
314+
}
315+
return nil
316+
}

client/cmd/job/job.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func NewJobCommand() *cobra.Command {
3333
NewValidateCommand(),
3434
NewJobRunInputCommand(),
3535
NewInspectCommand(),
36+
NewExportCommand(),
3637
)
3738
return cmd
3839
}

client/cmd/resource/create.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,16 @@ import (
1616
)
1717

1818
type createCommand struct {
19-
logger log.Logger
20-
clientConfig *config.ClientConfig
19+
logger log.Logger
20+
configFilePath string
2121

2222
namespaceSurvey *survey.NamespaceSurvey
2323
}
2424

2525
// NewCreateCommand initializes resource create command
26-
func NewCreateCommand(clientConfig *config.ClientConfig) *cobra.Command {
26+
func NewCreateCommand() *cobra.Command {
2727
l := logger.NewClientLogger()
2828
create := &createCommand{
29-
clientConfig: clientConfig,
3029
logger: l,
3130
namespaceSurvey: survey.NewNamespaceSurvey(l),
3231
}
@@ -37,11 +36,18 @@ func NewCreateCommand(clientConfig *config.ClientConfig) *cobra.Command {
3736
Example: "optimus resource create",
3837
RunE: create.RunE,
3938
}
39+
cmd.Flags().StringVarP(&create.configFilePath, "config", "c", create.configFilePath, "File path for client configuration")
40+
cmd.MarkFlagRequired("config")
4041
return cmd
4142
}
4243

4344
func (c createCommand) RunE(_ *cobra.Command, _ []string) error {
44-
selectedNamespace, err := c.namespaceSurvey.AskToSelectNamespace(c.clientConfig)
45+
cfg, err := config.LoadClientConfig(c.configFilePath)
46+
if err != nil {
47+
return err
48+
}
49+
50+
selectedNamespace, err := c.namespaceSurvey.AskToSelectNamespace(cfg)
4551
if err != nil {
4652
return err
4753
}

0 commit comments

Comments
 (0)