commit 4429f3fa1854fd61ebaf60469808d2ef2a3bec7c Author: Christopher Hase Date: Tue Sep 16 13:02:33 2025 +0200 feat(client): add basic client, model diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..88eb965 --- /dev/null +++ b/client/client.go @@ -0,0 +1,303 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "log" + "net/http" +) + +var ErrResourceNotFound = fmt.Errorf("resource not found") + +type EdgeConnect struct { + BaseURL string + HttpClient *http.Client + Credentials Credentials +} + +type Credentials struct { + Username string + Password string +} + +func (e *EdgeConnect) RetrieveToken(ctx context.Context) (string, error) { + json_data, err := json.Marshal(map[string]string{ + "username": e.Credentials.Username, + "password": e.Credentials.Password, + }) + if err != nil { + return "", err + } + + request, err := http.NewRequestWithContext(ctx, "POST", e.BaseURL+"/api/v1/login", bytes.NewBuffer(json_data)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + + resp, err := e.HttpClient.Do(request) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + var respData struct { + Token string `json:"token"` + } + err = json.NewDecoder(resp.Body).Decode(&respData) + if err != nil { + return "", err + } + + return respData.Token, nil +} + +func (e *EdgeConnect) CreateApp(ctx context.Context, input NewAppInput) error { + json_data, err := json.Marshal(input) + if err != nil { + return err + } + + response, err := call[App](ctx, e, "/api/v1/auth/ctrl/CreateApp", json_data) + if err != nil { + return err + } + + return response.Error() +} + +func (e *EdgeConnect) ShowApp(ctx context.Context, appkey AppKey, region string) (App, error) { + input := struct { + App App `json:"App"` + Region string `json:"Region"` + }{ + App: App{Key: appkey}, + Region: region, + } + + json_data, err := json.Marshal(input) + if err != nil { + return App{}, err + } + + responses, err := call[App](ctx, e, "/api/v1/auth/ctrl/ShowApp", json_data) + if err != nil { + return App{}, err + } + + if responses.StatusCode == http.StatusNotFound { + return App{}, fmt.Errorf("Error retrieving App: %w", ErrResourceNotFound) + } + + if !responses.IsSuccessful() { + return App{}, responses.Error() + } + + apps := responses.GetData() + if len(apps) > 0 { + return apps[0], nil + } + + return App{}, fmt.Errorf("could not find app with region/key: %s/%v: %w", region, appkey, ErrResourceNotFound) +} + +func (e *EdgeConnect) ShowApps(ctx context.Context, appkey AppKey, region string) ([]App, error) { + input := struct { + App App `json:"App"` + Region string `json:"Region"` + }{ + App: App{Key: appkey}, + Region: region, + } + + json_data, err := json.Marshal(input) + if err != nil { + return nil, err + } + + responses, err := call[App](ctx, e, "/api/v1/auth/ctrl/ShowApp", json_data) + if err != nil { + return nil, err + } + + if !responses.IsSuccessful() && responses.StatusCode != http.StatusNotFound { + return nil, responses.Error() + } + + return responses.GetData(), nil +} + +func (e *EdgeConnect) DeleteApp(ctx context.Context, appkey AppKey, region string) error { + input := struct { + App App `json:"App"` + Region string `json:"Region"` + }{ + App: App{Key: appkey}, + Region: region, + } + + json_data, err := json.Marshal(input) + if err != nil { + return err + } + + response, err := call[App](ctx, e, "/api/v1/auth/ctrl/DeleteApp", json_data) + if err != nil { + return err + } + + if !response.IsSuccessful() && response.StatusCode != 404 { + return response.Error() + } + + return nil +} + +func (e *EdgeConnect) CreateAppInstance(ctx context.Context, input NewAppInstanceInput) error { + json_data, err := json.Marshal(input) + if err != nil { + log.Printf("failed to marshal NewAppInstanceInput %v\n", err) + return err + } + + responses, err := call[AppInstance](ctx, e, "/api/v1/auth/ctrl/CreateAppInst", json_data) + if err != nil { + return err + } + + return responses.Error() +} + +func (e *EdgeConnect) ShowAppInstance(ctx context.Context, appinstkey AppInstanceKey, region string) (AppInstance, error) { + input := struct { + App AppInstance `json:"appinst"` + Region string `json:"Region"` + }{ + App: AppInstance{Key: appinstkey}, + Region: region, + } + + json_data, err := json.Marshal(input) + if err != nil { + return AppInstance{}, err + } + + responses, err := call[AppInstance](ctx, e, "/api/v1/auth/ctrl/ShowAppInst", json_data) + if err != nil { + return AppInstance{}, err + } + + if responses.StatusCode == http.StatusNotFound { + return AppInstance{}, fmt.Errorf("Error retrieving AppInstance: %w", ErrResourceNotFound) + } + + if !responses.IsSuccessful() { + return AppInstance{}, responses.Error() + } + + data := responses.GetData() + if len(data) > 0 { + return data[0], nil + } + + return AppInstance{}, fmt.Errorf("could not find app instance: %v: %w", responses, ErrResourceNotFound) +} + +func (e *EdgeConnect) ShowAppInstances(ctx context.Context, appinstkey AppInstanceKey, region string) ([]AppInstance, error) { + input := struct { + App AppInstance `json:"appinst"` + Region string `json:"Region"` + }{ + App: AppInstance{Key: appinstkey}, + Region: region, + } + + json_data, err := json.Marshal(input) + if err != nil { + return nil, err + } + + responses, err := call[AppInstance](ctx, e, "/api/v1/auth/ctrl/ShowAppInst", json_data) + if err != nil { + return nil, err + } + + if !responses.IsSuccessful() && responses.StatusCode != http.StatusNotFound { + return nil, responses.Error() + } + + return responses.GetData(), nil +} + +func (e *EdgeConnect) DeleteAppInstance(ctx context.Context, appinstancekey AppInstanceKey, region string) error { + input := struct { + AppInstance AppInstance `json:"appinst"` + Region string `json:"Region"` + }{ + AppInstance: AppInstance{Key: appinstancekey}, + Region: region, + } + + json_data, err := json.Marshal(input) + if err != nil { + return err + } + + responses, err := call[AppInstance](ctx, e, "/api/v1/auth/ctrl/DeleteAppInst", json_data) + if err != nil { + return err + } + + return responses.Error() +} + +func call[T Message](ctx context.Context, client *EdgeConnect, path string, body []byte) (Responses[T], error) { + token, err := client.RetrieveToken(ctx) + if err != nil { + return Responses[T]{}, err + } + + request, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s%s", client.BaseURL, path), bytes.NewBuffer(body)) + if err != nil { + return Responses[T]{}, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := client.HttpClient.Do(request) + if err != nil { + return Responses[T]{}, err + } + defer resp.Body.Close() + + responses := Responses[T]{} + responses.StatusCode = resp.StatusCode + + if responses.StatusCode == http.StatusNotFound { + return responses, nil + } + + decoder := json.NewDecoder(resp.Body) + for { + var d Response[T] + if err := decoder.Decode(&d); err != nil { + if err.Error() == "EOF" { + break + } + log.Printf("Error in call %s: %v", path, err) + return Responses[T]{}, fmt.Errorf("Error in call %s: %w", path, err) + } + responses.Responses = append(responses.Responses, d) + } + + log.Printf("call(): %s resulting in http status %v and %v responses\n", path, resp.StatusCode, len(responses.GetMessages())) + for i, v := range responses.GetMessages() { + log.Printf("call(): response[%v]: %s\n", i, v) + } + + return responses, nil +} diff --git a/client/models.go b/client/models.go new file mode 100644 index 0000000..c46bc93 --- /dev/null +++ b/client/models.go @@ -0,0 +1,125 @@ +package client + +import "fmt" + +type Responses[T Message] struct { + Responses []Response[T] + StatusCode int +} + +type Message interface { + GetMessage() string +} + +func (r *Responses[T]) GetData() []T { + var data []T + for _, v := range r.Responses { + if v.HasData() { + data = append(data, v.Data) + } + } + return data +} + +func (r *Responses[T]) GetMessages() []string { + var messages []string + for _, v := range r.Responses { + if v.IsMessage() { + messages = append(messages, v.Data.GetMessage()) + } + } + return messages +} + +func (r *Responses[T]) IsSuccessful() bool { + return r.StatusCode < 400 && r.StatusCode > 0 +} + +func (r *Responses[T]) Error() error { + if r.IsSuccessful() { + return nil + } + + return fmt.Errorf("error with status code %v and messages %v", r.StatusCode, r.GetMessages()) +} + +type Response[T Message] struct { + Data T `json:"data"` +} + +func (res *Response[T]) HasData() bool { + return !res.IsMessage() +} + +func (res *Response[T]) IsMessage() bool { + return res.Data.GetMessage() != "" +} + +type NewAppInstanceInput struct { + Region string `json:"region"` + AppInst AppInstance `json:"appinst"` +} + +type msg struct { + Message string `json:"message"` +} + +func (msg msg) GetMessage() string { + return msg.Message +} + +type AppInstance struct { + msg `json:",inline"` + Key AppInstanceKey `json:"key"` + AppKey AppKey `json:"app_key,omitzero"` + Flavor Flavor `json:"flavor,omitzero"` + State string `json:"state,omitempty"` + PowerState string `json:"power_state,omitempty"` +} + +type AppInstanceKey struct { + Organization string `json:"organization"` + Name string `json:"name"` + CloudletKey CloudletKey `json:"cloudlet_key"` +} + +type CloudletKey struct { + Organization string `json:"organization"` + Name string `json:"name"` +} + +type AppKey struct { + Organization string `json:"organization"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` +} + +type Flavor struct { + Name string `json:"name"` +} + +type NewAppInput struct { + Region string `json:"region"` + App App `json:"app"` +} + +type SecurityRule struct { + PortRangeMax int `json:"port_range_max"` + PortRangeMin int `json:"port_range_min"` + Protocol string `json:"protocol"` + RemoteCIDR string `json:"remote_cidr"` +} + +type App struct { + msg `json:",inline"` + Key AppKey `json:"key"` + Deployment string `json:"deployment,omitempty"` + ImageType string `json:"image_type,omitempty"` + ImagePath string `json:"image_path,omitempty"` + AllowServerless bool `json:"allow_serverless,omitempty"` + DefaultFlavor Flavor `json:"defaultFlavor,omitempty"` + ServerlessConfig any `json:"serverless_config,omitempty"` + DeploymentGenerator string `json:"deployment_generator,omitempty"` + DeploymentManifest string `json:"deployment_manifest,omitempty"` + RequiredOutboundConnections []SecurityRule `json:"required_outbound_connections"` +}