-
Notifications
You must be signed in to change notification settings - Fork 2
Add Persys Go SDK scaffold #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # Persys Go SDK | ||
|
|
||
| The Persys Go SDK is the reusable client library for Persys Cloud. It keeps `persysctl` thin by centralizing transport setup, certificate handling, manifest ingestion, and GitOps watching. | ||
|
|
||
| ```go | ||
| c, err := sdk.New(sdk.DefaultOptions()) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer c.Close() | ||
| ``` | ||
|
|
||
| ## Packages | ||
|
|
||
| - `client`: core HTTP/gRPC client implementation. | ||
| - `options`: configuration options and defaults. | ||
| - `ingestion`: YAML, JSON, Docker Compose, and Git URL conversion helpers. | ||
| - `gitops`: local directory and remote repository watch loops. | ||
| - `types`: SDK-only non-protobuf data structures. | ||
|
|
||
| All protobuf request and response types are imported from `github.com/persys-dev/persys-cloud/pkg/scheduler/controlv1`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package client | ||
|
|
||
| import ( | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/persys-dev/persys-cloud/pkg/certmanager" | ||
| "github.com/persys-dev/persys-cloud/sdk/options" | ||
| "github.com/sirupsen/logrus" | ||
| ) | ||
|
|
||
| // LoadTLSConfig builds a tls.Config from SDK options and the shared Persys certmanager settings. | ||
| func LoadTLSConfig(opts *options.Options) (*tls.Config, error) { | ||
| if opts == nil { | ||
| opts = options.DefaultOptions() | ||
| } | ||
| if opts.Insecure { | ||
| return &tls.Config{InsecureSkipVerify: true}, nil | ||
| } | ||
| if !opts.UseCertManager { | ||
| return &tls.Config{MinVersion: tls.VersionTLS12}, nil | ||
| } | ||
| mgr := certmanager.NewManager(certmanager.Config{TLSEnabled: true, TLSCertPath: opts.TLSCertPath, TLSKeyPath: opts.TLSKeyPath, TLSCAPath: opts.TLSCAPath}, logrus.New()) | ||
| if err := mgr.Validate(); err != nil { | ||
| return nil, fmt.Errorf("validate certmanager config: %w", err) | ||
| } | ||
| cert, err := tls.LoadX509KeyPair(opts.TLSCertPath, opts.TLSKeyPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("load client certificate: %w", err) | ||
| } | ||
| caPEM, err := os.ReadFile(opts.TLSCAPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("read CA certificate: %w", err) | ||
| } | ||
| pool := x509.NewCertPool() | ||
| if !pool.AppendCertsFromPEM(caPEM) { | ||
| return nil, fmt.Errorf("parse CA certificate %q", opts.TLSCAPath) | ||
| } | ||
| return &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}, RootCAs: pool}, nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| // Package client implements the Persys Cloud SDK client. | ||
| package client | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
| "time" | ||
|
|
||
| controlv1 "github.com/persys-dev/persys-cloud/pkg/scheduler/controlv1" | ||
| "github.com/persys-dev/persys-cloud/sdk/options" | ||
| "google.golang.org/grpc" | ||
| "google.golang.org/grpc/credentials" | ||
| "google.golang.org/grpc/credentials/insecure" | ||
| ) | ||
|
|
||
| // Options is re-exported for callers that import only the client package. | ||
| type Options = options.Options | ||
|
|
||
| // DefaultOptions returns SDK defaults. | ||
| func DefaultOptions() *Options { return options.DefaultOptions() } | ||
|
|
||
| // PersysClient is the main public API for Persys Cloud interactions. | ||
| type PersysClient interface { | ||
| ApplyWorkload(context.Context, *controlv1.ApplyWorkloadRequest) (*controlv1.ApplyWorkloadResponse, error) | ||
| DeleteWorkload(context.Context, *controlv1.DeleteWorkloadRequest) (*controlv1.DeleteWorkloadResponse, error) | ||
| RetryWorkload(context.Context, *controlv1.RetryWorkloadRequest) (*controlv1.RetryWorkloadResponse, error) | ||
| ListNodes(context.Context, *controlv1.ListNodesRequest) (*controlv1.ListNodesResponse, error) | ||
| GetNode(context.Context, *controlv1.GetNodeRequest) (*controlv1.GetNodeResponse, error) | ||
| ListWorkloads(context.Context, *controlv1.ListWorkloadsRequest) (*controlv1.ListWorkloadsResponse, error) | ||
| GetWorkload(context.Context, *controlv1.GetWorkloadRequest) (*controlv1.GetWorkloadResponse, error) | ||
| GetClusterSummary(context.Context, *controlv1.GetClusterSummaryRequest) (*controlv1.GetClusterSummaryResponse, error) | ||
| Close() error | ||
| } | ||
|
|
||
| // Client is a reusable Persys Cloud client that supports gRPC and HTTP transport. | ||
| type Client struct { | ||
| opts *options.Options | ||
| http *http.Client | ||
| conn *grpc.ClientConn | ||
| grpc controlv1.AgentControlClient | ||
| } | ||
|
|
||
| // New creates a Persys SDK client from options. | ||
| func New(opts *options.Options) (*Client, error) { | ||
| if opts == nil { | ||
| opts = options.DefaultOptions() | ||
| } | ||
| if opts.Timeout <= 0 { | ||
| opts.Timeout = 30 * time.Second | ||
| } | ||
| c := &Client{opts: opts, http: &http.Client{Timeout: opts.Timeout}} | ||
| switch opts.Transport { | ||
| case "", options.TransportGRPC: | ||
| dialOpts := []grpc.DialOption{} | ||
| if opts.Insecure { | ||
| dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) | ||
| } else { | ||
| tlsCfg, err := LoadTLSConfig(opts) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))) | ||
| } | ||
| conn, err := grpc.NewClient(opts.GRPCEndpoint, dialOpts...) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create grpc client: %w", err) | ||
| } | ||
| c.conn = conn | ||
| c.grpc = controlv1.NewAgentControlClient(conn) | ||
| case options.TransportHTTP: | ||
| // HTTP gateway support is intentionally initialized lazily by endpoint-specific helpers. | ||
| default: | ||
| return nil, fmt.Errorf("unsupported transport %q", opts.Transport) | ||
| } | ||
| return c, nil | ||
| } | ||
|
|
||
| // Close releases client resources. | ||
| func (c *Client) Close() error { | ||
| if c != nil && c.conn != nil { | ||
| return c.conn.Close() | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (c *Client) requireGRPC() (controlv1.AgentControlClient, error) { | ||
| if c == nil || c.grpc == nil { | ||
| return nil, fmt.Errorf("grpc transport is not configured") | ||
| } | ||
| return c.grpc, nil | ||
| } | ||
|
|
||
| // ApplyWorkload applies or updates a workload. | ||
| func (c *Client) ApplyWorkload(ctx context.Context, req *controlv1.ApplyWorkloadRequest) (*controlv1.ApplyWorkloadResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.ApplyWorkload(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("apply workload: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| // DeleteWorkload deletes a workload. | ||
| func (c *Client) DeleteWorkload(ctx context.Context, req *controlv1.DeleteWorkloadRequest) (*controlv1.DeleteWorkloadResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.DeleteWorkload(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("delete workload: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| // RetryWorkload retries a workload. | ||
| func (c *Client) RetryWorkload(ctx context.Context, req *controlv1.RetryWorkloadRequest) (*controlv1.RetryWorkloadResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.RetryWorkload(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("retry workload: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| // ListNodes lists scheduler nodes. | ||
| func (c *Client) ListNodes(ctx context.Context, req *controlv1.ListNodesRequest) (*controlv1.ListNodesResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.ListNodes(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("list nodes: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| // GetNode gets a node. | ||
| func (c *Client) GetNode(ctx context.Context, req *controlv1.GetNodeRequest) (*controlv1.GetNodeResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.GetNode(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("get node: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| // ListWorkloads lists workloads. | ||
| func (c *Client) ListWorkloads(ctx context.Context, req *controlv1.ListWorkloadsRequest) (*controlv1.ListWorkloadsResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.ListWorkloads(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("list workloads: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| // GetWorkload gets a workload. | ||
| func (c *Client) GetWorkload(ctx context.Context, req *controlv1.GetWorkloadRequest) (*controlv1.GetWorkloadResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.GetWorkload(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("get workload: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
|
|
||
| // GetClusterSummary gets cluster status. | ||
| func (c *Client) GetClusterSummary(ctx context.Context, req *controlv1.GetClusterSummaryRequest) (*controlv1.GetClusterSummaryResponse, error) { | ||
| gc, err := c.requireGRPC() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := gc.GetClusterSummary(ctx, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("get cluster summary: %w", err) | ||
| } | ||
| return resp, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package client | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/persys-dev/persys-cloud/sdk/options" | ||
| ) | ||
|
|
||
| func TestNewGRPCInsecure(t *testing.T) { | ||
| c, err := New(&options.Options{Transport: options.TransportGRPC, GRPCEndpoint: "localhost:50051", Insecure: true}) | ||
| if err != nil { | ||
| t.Fatalf("New() error = %v", err) | ||
| } | ||
| if c == nil { | ||
| t.Fatal("New() returned nil client") | ||
| } | ||
| if err := c.Close(); err != nil { | ||
| t.Fatalf("Close() error = %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestNewRejectsUnknownTransport(t *testing.T) { | ||
| _, err := New(&options.Options{Transport: "bogus"}) | ||
| if err == nil { | ||
| t.Fatal("New() expected error for unknown transport") | ||
| } | ||
| } | ||
|
|
||
| func TestLoadTLSConfigInsecure(t *testing.T) { | ||
| cfg, err := LoadTLSConfig(&options.Options{Insecure: true}) | ||
| if err != nil { | ||
| t.Fatalf("LoadTLSConfig() error = %v", err) | ||
| } | ||
| if cfg == nil || !cfg.InsecureSkipVerify { | ||
| t.Fatal("expected insecure TLS config") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| // Package gitops watches local and remote sources and triggers SDK applies. | ||
| package gitops | ||
|
|
||
| import "time" | ||
|
|
||
| // WatchOptions configures a GitOps watch loop. | ||
| type WatchOptions struct { | ||
| Path, RepoURL, Ref string | ||
| Interval time.Duration | ||
| } | ||
|
|
||
| // Event describes a detected change. | ||
| type Event struct { | ||
| Path string | ||
| Time time.Time | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package gitops | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os/exec" | ||
| "time" | ||
|
|
||
| "github.com/fsnotify/fsnotify" | ||
| ) | ||
|
|
||
| // WatchLocal watches a local directory and calls apply when files change. | ||
| func WatchLocal(ctx context.Context, opts WatchOptions, apply func(context.Context, Event) error) error { | ||
| w, err := fsnotify.NewWatcher() | ||
| if err != nil { | ||
| return fmt.Errorf("create watcher: %w", err) | ||
| } | ||
| defer w.Close() | ||
| if err := w.Add(opts.Path); err != nil { | ||
| return fmt.Errorf("watch %q: %w", opts.Path, err) | ||
| } | ||
| for { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return ctx.Err() | ||
| case err := <-w.Errors: | ||
| if err != nil { | ||
| return fmt.Errorf("watch error: %w", err) | ||
| } | ||
| case ev := <-w.Events: | ||
| if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 { | ||
| if err := apply(ctx, Event{Path: ev.Name, Time: time.Now()}); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // WatchRemote periodically runs git pull in Path and calls apply after successful updates. | ||
| func WatchRemote(ctx context.Context, opts WatchOptions, apply func(context.Context, Event) error) error { | ||
| interval := opts.Interval | ||
| if interval <= 0 { | ||
| interval = time.Minute | ||
| } | ||
| t := time.NewTicker(interval) | ||
| defer t.Stop() | ||
| for { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return ctx.Err() | ||
| case <-t.C: | ||
| out, err := exec.CommandContext(ctx, "git", "-C", opts.Path, "pull", "--ff-only").CombinedOutput() | ||
| if err != nil { | ||
| return fmt.Errorf("git pull: %w: %s", err, out) | ||
| } | ||
| if err := apply(ctx, Event{Path: opts.Path, Time: time.Now()}); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.