diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..eca2ef3 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,443 @@ +# Persys Go SDK + +The **Persys Go SDK** is the official Go client library for interacting with Persys Cloud. + +It provides a unified interface for building Persys-aware tools, controllers, operators, automation systems, and internal services by handling the common platform concerns: + +- API communication + +- gRPC transport management + +- TLS and certificate configuration + +- authentication setup + +- resource manifest ingestion + +- GitOps synchronization + +- Persys resource serialization + + +The SDK is designed to keep higher-level applications lightweight. Tools such as `persysctl`, automation agents, and external controllers use this package instead of implementing their own Persys API clients. + +--- + +## Design Goals + +The SDK follows a few core principles: + +### Thin Clients + +Applications using the SDK should focus on intent: + +```go +client.Apply(ctx, manifest) +``` + +rather than managing: + +- connections + +- protobuf encoding + +- certificates + +- retries + +- transport configuration + + +--- + +### Native Persys API Access + +The SDK exposes Persys Cloud APIs directly using generated protobuf types. + +All scheduler control API types are imported from: + +``` +github.com/persys-dev/persys-cloud/pkg/scheduler/controlv1 +``` + +Example: + +```go +import controlv1 "github.com/persys-dev/persys-cloud/pkg/scheduler/controlv1" +``` + +This keeps the SDK aligned with the Persys control plane API. + +--- + +# Installation + +```bash +go get github.com/persys-dev/persys-go-sdk +``` + +--- + +# Quick Start + +Create a client using the default configuration: + +```go +package main + +import ( + "context" + + sdk "github.com/persys-dev/persys-go-sdk" +) + +func main() { + client, err := sdk.New(sdk.DefaultOptions()) + if err != nil { + panic(err) + } + + defer client.Close() + + ctx := context.Background() + + resources, err := client.List(ctx) + if err != nil { + panic(err) + } + + _ = resources +} +``` + +The SDK automatically configures: + +- API endpoints + +- TLS + +- certificates + +- connection lifecycle + +- authentication providers + + +--- + +# Architecture + +The SDK is divided into focused packages: + +``` +persys-go-sdk +│ +├── client +│ ├── API client +│ ├── gRPC transport +│ ├── authentication +│ └── connection lifecycle +│ +├── options +│ ├── client configuration +│ ├── TLS options +│ ├── endpoints +│ └── defaults +│ +├── ingestion +│ ├── YAML parser +│ ├── JSON parser +│ ├── Docker Compose converter +│ └── Git source loader +│ +├── gitops +│ ├── repository watchers +│ ├── filesystem watchers +│ └── reconciliation loops +│ +└── types + └── SDK-specific models +``` + +--- + +# Packages + +## client + +The core SDK package. + +Responsible for communication with the Persys control plane. + +Provides: + +- gRPC connection management + +- API calls + +- authentication + +- certificate loading + +- request handling + + +Example: + +```go +client, err := sdk.New(opts) + +vm, err := client.GetVM( + ctx, + "production-api", +) +``` + +--- + +## options + +Contains SDK configuration. + +Example: + +```go +opts := sdk.Options{ + Endpoint: "scheduler.persys.local:443", + TLS: sdk.TLSOptions{ + Enabled: true, + }, +} + +client, err := sdk.New(opts) +``` + +Defaults can be loaded using: + +```go +sdk.DefaultOptions() +``` + +--- + +## ingestion + +Converts external configuration formats into Persys resources. + +Supported inputs: + +- YAML manifests + +- JSON manifests + +- Docker Compose files + +- Git repositories + + +Example: + +```go +resources, err := ingestion.FromYAML( + data, +) +``` + +The ingestion layer allows Persys to accept existing deployment formats without requiring users to rewrite configuration. + +--- + +## gitops + +Provides GitOps synchronization primitives. + +It watches: + +- local directories + +- remote repositories + + +and triggers reconciliation when changes occur. + +Example: + +```go +watcher := gitops.NewWatcher( + repo, + handler, +) + +watcher.Start(ctx) +``` + +Typical usage: + +``` +Git repository + | + v + GitOps watcher + | + v + Manifest ingestion + | + v + Persys API + | + v + Scheduler reconciliation +``` + +--- + +# Certificates and Security + +The SDK manages Persys cluster certificates and secure communication. + +It supports: + +- CA certificates + +- client certificates + +- mutual TLS + +- secure gRPC connections + + +Applications should not manually create gRPC connections. + +Instead: + +```go +client, err := sdk.New(opts) +``` + +will configure the correct transport. + +--- + +# Resource Flow + +A typical SDK workflow: + +``` +User Input + | + v +Ingestion Layer + | + v +Persys Types + | + v +SDK Client + | + v +Scheduler API + | + v +Persys Control Plane +``` + +--- + +# Usage in Persys Components + +The SDK is used by: + +## persysctl + +Command-line interface. + +Responsibilities: + +- user interaction + +- command parsing + +- output formatting + + +The SDK handles: + +- API communication + +- manifests + +- authentication + + +--- + +## Controllers + +External controllers can use the SDK to: + +- watch resources + +- create workloads + +- update state + +- reconcile desired state + + +--- + +## Automation + +Automation services can use: + +- GitOps integration + +- ingestion + +- API access + + +without implementing Persys protocols. + +--- + +# Versioning + +The SDK follows Persys API compatibility. + +Changes to protobuf APIs may require corresponding SDK updates. + +Recommended dependency: + +```go +require ( + github.com/persys-dev/persys-go-sdk vX.Y.Z +) +``` + +--- + +# Closing Connections + +SDK clients should always be closed: + +```go +defer client.Close() +``` + +This releases: + +- gRPC connections + +- watchers + +- background workers + + +--- + +# Philosophy + +The Persys Go SDK exists to make Persys automation feel like a native Go platform. + +Applications should describe **what they want**, while the SDK handles **how Persys communicates and reconciles it**. diff --git a/sdk/client/certs.go b/sdk/client/certs.go new file mode 100644 index 0000000..3266e38 --- /dev/null +++ b/sdk/client/certs.go @@ -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 +} diff --git a/sdk/client/client.go b/sdk/client/client.go new file mode 100644 index 0000000..acef375 --- /dev/null +++ b/sdk/client/client.go @@ -0,0 +1,321 @@ +// Package client implements the Persys Cloud SDK client. +package client + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "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" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// 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.DialContext(context.Background(), 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: + if strings.TrimSpace(opts.APIEndpoint) == "" { + return nil, fmt.Errorf("api endpoint is required for http transport") + } + default: + return nil, fmt.Errorf("unsupported transport %q", opts.Transport) + } + return c, nil +} + +func (c *Client) httpURL(path string, query map[string]string) (string, error) { + if c == nil || c.opts == nil { + return "", fmt.Errorf("client is not configured") + } + base, err := url.Parse(strings.TrimRight(c.opts.APIEndpoint, "/")) + if err != nil { + return "", fmt.Errorf("parse api endpoint: %w", err) + } + base.Path = strings.TrimRight(base.Path, "/") + path + values := base.Query() + for k, v := range query { + if strings.TrimSpace(v) != "" { + values.Set(k, v) + } + } + base.RawQuery = values.Encode() + return base.String(), nil +} + +func (c *Client) doProtoHTTP(ctx context.Context, method, path string, in proto.Message, out proto.Message, query map[string]string) error { + endpoint, err := c.httpURL(path, query) + if err != nil { + return err + } + var body io.Reader + if in != nil { + data, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(in) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + body = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return fmt.Errorf("create http request: %w", err) + } + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("send http request: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read http response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("http %s %s failed with status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data))) + } + if out == nil || len(strings.TrimSpace(string(data))) == 0 { + return nil + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(data, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.ApplyWorkloadResponse{} + if err := c.doProtoHTTP(ctx, http.MethodPost, "/workloads/schedule", req, resp, nil); err != nil { + return nil, fmt.Errorf("apply workload: %w", err) + } + return resp, nil + } + 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.DeleteWorkloadResponse{} + if err := c.doProtoHTTP(ctx, http.MethodDelete, "/workloads/"+url.PathEscape(req.GetWorkloadId()), nil, resp, nil); err != nil { + return nil, fmt.Errorf("delete workload: %w", err) + } + return resp, nil + } + 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.RetryWorkloadResponse{} + if err := c.doProtoHTTP(ctx, http.MethodPost, "/workloads/"+url.PathEscape(req.GetWorkloadId())+"/retry", nil, resp, nil); err != nil { + return nil, fmt.Errorf("retry workload: %w", err) + } + return resp, nil + } + 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.ListNodesResponse{} + if err := c.doProtoHTTP(ctx, http.MethodGet, "/nodes", nil, resp, map[string]string{"status": req.GetStatus()}); err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + return resp, nil + } + 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.GetNodeResponse{} + if err := c.doProtoHTTP(ctx, http.MethodGet, "/nodes/"+url.PathEscape(req.GetNodeId()), nil, resp, nil); err != nil { + return nil, fmt.Errorf("get node: %w", err) + } + return resp, nil + } + 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.ListWorkloadsResponse{} + if err := c.doProtoHTTP(ctx, http.MethodGet, "/workloads", nil, resp, map[string]string{"status": req.GetStatus()}); err != nil { + return nil, fmt.Errorf("list workloads: %w", err) + } + return resp, nil + } + 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.GetWorkloadResponse{} + if err := c.doProtoHTTP(ctx, http.MethodGet, "/workloads/"+url.PathEscape(req.GetWorkloadId()), nil, resp, nil); err != nil { + return nil, fmt.Errorf("get workload: %w", err) + } + return resp, nil + } + 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) { + if c != nil && c.opts != nil && c.opts.Transport == options.TransportHTTP { + resp := &controlv1.GetClusterSummaryResponse{} + if err := c.doProtoHTTP(ctx, http.MethodGet, "/cluster/metrics", nil, resp, nil); err != nil { + return nil, fmt.Errorf("get cluster summary: %w", err) + } + return resp, nil + } + 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 +} diff --git a/sdk/client/client_test.go b/sdk/client/client_test.go new file mode 100644 index 0000000..0b928fa --- /dev/null +++ b/sdk/client/client_test.go @@ -0,0 +1,89 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + controlv1 "github.com/persys-dev/persys-cloud/pkg/scheduler/controlv1" + "github.com/persys-dev/persys-cloud/sdk/options" + "google.golang.org/protobuf/encoding/protojson" +) + +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 TestNewHTTPTransport(t *testing.T) { + c, err := New(&options.Options{Transport: options.TransportHTTP, APIEndpoint: "http://localhost:8080"}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if c == nil { + t.Fatal("New() returned nil client") + } +} + +func TestHTTPApplyWorkloadUsesGatewayEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/workloads/schedule" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + data, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(&controlv1.ApplyWorkloadResponse{Success: true}) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + _, _ = w.Write(data) + })) + defer server.Close() + + c, err := New(&options.Options{Transport: options.TransportHTTP, APIEndpoint: server.URL}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + resp, err := c.ApplyWorkload(context.Background(), &controlv1.ApplyWorkloadRequest{WorkloadId: "workload-1"}) + if err != nil { + t.Fatalf("ApplyWorkload() error = %v", err) + } + if !resp.GetSuccess() { + t.Fatal("ApplyWorkload() returned unsuccessful response") + } +} + +func TestDefaultOptionsDoNotRequireCertificatePaths(t *testing.T) { + opts := options.DefaultOptions() + if opts.UseCertManager { + t.Fatal("DefaultOptions() should not require certmanager certificate paths") + } + if _, err := New(opts); err != nil { + t.Fatalf("New(DefaultOptions()) error = %v", err) + } +} + +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") + } +} diff --git a/sdk/gitops/types.go b/sdk/gitops/types.go new file mode 100644 index 0000000..35fe4f5 --- /dev/null +++ b/sdk/gitops/types.go @@ -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 +} diff --git a/sdk/gitops/watcher.go b/sdk/gitops/watcher.go new file mode 100644 index 0000000..1bd8e30 --- /dev/null +++ b/sdk/gitops/watcher.go @@ -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 + } + } + } +} diff --git a/sdk/go.mod b/sdk/go.mod new file mode 100644 index 0000000..32abf91 --- /dev/null +++ b/sdk/go.mod @@ -0,0 +1,37 @@ +module github.com/persys-dev/persys-cloud/sdk + +go 1.24.13 + +require ( + github.com/fsnotify/fsnotify v1.9.0 + github.com/persys-dev/persys-cloud/pkg v0.0.0 + github.com/sirupsen/logrus v1.9.4 + google.golang.org/grpc v1.79.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.22.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace github.com/persys-dev/persys-cloud/pkg => ../pkg diff --git a/sdk/go.sum b/sdk/go.sum new file mode 100644 index 0000000..b08b615 --- /dev/null +++ b/sdk/go.sum @@ -0,0 +1,95 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdk/ingestion/compose.go b/sdk/ingestion/compose.go new file mode 100644 index 0000000..3d03866 --- /dev/null +++ b/sdk/ingestion/compose.go @@ -0,0 +1,15 @@ +package ingestion + +import "fmt" + +// ConvertCompose parses a Docker Compose document and returns the normalized document. +func ConvertCompose(data []byte) (*Document, error) { + doc, err := Convert(data) + if err != nil { + return nil, err + } + if doc.Raw["services"] == nil { + return nil, fmt.Errorf("compose manifest missing services") + } + return doc, nil +} diff --git a/sdk/ingestion/converter.go b/sdk/ingestion/converter.go new file mode 100644 index 0000000..59743ad --- /dev/null +++ b/sdk/ingestion/converter.go @@ -0,0 +1,55 @@ +// Package ingestion converts user manifests into SDK/protobuf-ready structures. +package ingestion + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/persys-dev/persys-cloud/sdk/types" + "gopkg.in/yaml.v3" +) + +// Document is a normalized ingestion result. +type Document struct { + Stack *types.PersysStack + Raw map[string]interface{} + GitSource *types.GitSource +} + +// Convert parses YAML, JSON, Docker Compose, or Git URL input. +func Convert(data []byte) (*Document, error) { + text := strings.TrimSpace(string(data)) + if IsGitURL(text) { + return &Document{GitSource: &types.GitSource{URL: text}}, nil + } + var raw map[string]interface{} + if json.Unmarshal(data, &raw) != nil { + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + } + doc := &Document{Raw: raw} + if kind, _ := raw["kind"].(string); strings.EqualFold(kind, "PersysStack") || raw["services"] != nil { + stack := &types.PersysStack{} + buf, _ := json.Marshal(raw) + _ = json.Unmarshal(buf, stack) + doc.Stack = stack + } + return doc, nil +} + +// IsGitURL reports whether value looks like a remote Git repository URL. +func IsGitURL(value string) bool { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "git@") || strings.HasSuffix(value, ".git") { + return true + } + u, err := url.Parse(value) + return err == nil && (u.Scheme == "http" || u.Scheme == "https" || u.Scheme == "ssh") && strings.Contains(u.Path, ".git") +} + +// EncodeCompose returns the base64 representation persysctl historically sent for compose files. +func EncodeCompose(data []byte) string { return base64.StdEncoding.EncodeToString(data) } diff --git a/sdk/options/options.go b/sdk/options/options.go new file mode 100644 index 0000000..828f15a --- /dev/null +++ b/sdk/options/options.go @@ -0,0 +1,30 @@ +// Package options defines configuration values for the Persys Cloud SDK. +package options + +import "time" + +const ( + // TransportHTTP sends requests through the Persys HTTP gateway. + TransportHTTP = "http" + // TransportGRPC sends requests directly to the Persys scheduler gRPC API. + TransportGRPC = "grpc" +) + +// Options contains user-configurable SDK settings. +type Options struct { + Transport string `yaml:"transport"` + APIEndpoint string `yaml:"api_endpoint"` + GRPCEndpoint string `yaml:"grpc_endpoint"` + Timeout time.Duration `yaml:"timeout"` + Insecure bool `yaml:"insecure"` + UseCertManager bool `yaml:"use_cert_manager"` + + TLSCertPath string `yaml:"tls_cert_path"` + TLSKeyPath string `yaml:"tls_key_path"` + TLSCAPath string `yaml:"tls_ca_path"` +} + +// DefaultOptions returns SDK defaults compatible with persysctl conventions. +func DefaultOptions() *Options { + return &Options{Transport: TransportGRPC, APIEndpoint: "http://localhost:8080", GRPCEndpoint: "localhost:50051", Timeout: 30 * time.Second, UseCertManager: false} +} diff --git a/sdk/sdk.go b/sdk/sdk.go new file mode 100644 index 0000000..4dc54cb --- /dev/null +++ b/sdk/sdk.go @@ -0,0 +1,19 @@ +// Package sdk provides the public Persys Cloud Go SDK entry points. +package sdk + +import ( + "github.com/persys-dev/persys-cloud/sdk/client" + "github.com/persys-dev/persys-cloud/sdk/options" +) + +// Options configures a Persys SDK client. +type Options = options.Options + +// Client is the reusable Persys Cloud client. +type Client = client.Client + +// DefaultOptions returns default SDK options. +func DefaultOptions() *Options { return options.DefaultOptions() } + +// New creates a Persys Cloud SDK client. +func New(opts *Options) (*Client, error) { return client.New(opts) } diff --git a/sdk/types/types.go b/sdk/types/types.go new file mode 100644 index 0000000..ae82a32 --- /dev/null +++ b/sdk/types/types.go @@ -0,0 +1,14 @@ +// Package types contains SDK-only types that are not generated from protobuf. +package types + +// PersysStack is a lightweight SDK representation for multi-workload stack manifests. +type PersysStack struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Spec map[string]interface{} `json:"spec,omitempty" yaml:"spec,omitempty"` + Workloads []map[string]interface{} `json:"workloads,omitempty" yaml:"workloads,omitempty"` +} + +// GitSource identifies a remote Git manifest source. +type GitSource struct{ URL, Ref, Path string }