diff --git a/go.mod b/go.mod index a6d7770..de10365 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/golangci/golangci-lint v1.51.2 github.com/google/go-querystring v1.1.0 github.com/ory/go-acc v0.2.8 + gopkg.in/yaml.v2 v2.4.0 ) require ( @@ -178,7 +179,6 @@ require ( golang.org/x/tools v0.6.0 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.4.2 // indirect mvdan.cc/gofumpt v0.4.0 // indirect diff --git a/pkg/api/cloud/image/models.go b/pkg/api/cloud/image/models.go index c01e144..d85cc8d 100644 --- a/pkg/api/cloud/image/models.go +++ b/pkg/api/cloud/image/models.go @@ -5,7 +5,7 @@ import ( ) type ( - // Client is a Service to work with API Jobs. + // Client is a Service to work with API. Client struct { client *api.Client } diff --git a/pkg/api/cloud/stack/add.go b/pkg/api/cloud/stack/add.go index 36ebfba..3168cbe 100644 --- a/pkg/api/cloud/stack/add.go +++ b/pkg/api/cloud/stack/add.go @@ -6,7 +6,9 @@ import ( "net/url" "strconv" + "github.com/sitehostnz/gosh/pkg/models" "github.com/sitehostnz/gosh/pkg/utils" + "github.com/sitehostnz/gosh/pkg/utils/dockercompose" ) // Add creates a new cloud stack. @@ -50,3 +52,63 @@ func (s *Client) Add(ctx context.Context, request AddRequest) (response AddRespo return response, nil } + +// AddWithImage creates a new cloud stack with an image (Web Image and Service Image are supported). +func (s *Client) AddWithImage(ctx context.Context, request AddRequestWithImage) (response AddResponse, err error) { + uri := "cloud/stack/add.json" + keys := []string{ + "client_id", + "server", + "name", + "label", + "enable_ssl", + "docker_compose", + } + + values := url.Values{} + values.Add("client_id", s.client.ClientID) + values.Add("server", request.ServerName) + values.Add("name", request.Name) + values.Add("label", request.Label) + values.Add("enable_ssl", strconv.Itoa(request.EnableSSL)) + + // Set the registry path + registryPath := "registry-staging.sitehost.co.nz" + if request.ImageProvider == ImageProviderCustom { + registryPath = "registry-clients.sitehost.co.nz" + } + + // Generate Docker Compose file + dockerCompose, err := dockercompose.GenerateDockerCompose(ctx, s.client, models.GenerateDockerComposeRequest{ + Name: request.Name, + Label: request.Label, + RegistryPath: registryPath, + ImageCode: request.ImageCode, + }) + if err != nil { + return response, err + } + + values.Add("docker_compose", dockerCompose) + + var vars string + for _, envVar := range request.EnvironmentVariables { + vars += fmt.Sprintf(" %s: %s\n", envVar.Name, envVar.Content) + } + + if vars != "" { + values.Add("environments["+request.Name+".env]", fmt.Sprintf("vars: \n%s", vars)) + keys = append(keys, "environments["+request.Name+".env]") + } + + req, err := s.client.NewRequest("POST", uri, utils.Encode(values, keys)) + if err != nil { + return response, err + } + + if err := s.client.Do(ctx, req, &response); err != nil { + return response, err + } + + return response, nil +} diff --git a/pkg/api/cloud/stack/environment/models.go b/pkg/api/cloud/stack/environment/models.go index dec4a4f..bb5adfc 100644 --- a/pkg/api/cloud/stack/environment/models.go +++ b/pkg/api/cloud/stack/environment/models.go @@ -5,7 +5,7 @@ import ( ) type ( - // Client is a Service to work with API Jobs. + // Client is a Service to work with API. Client struct { client *api.Client } diff --git a/pkg/api/cloud/stack/image/doc.go b/pkg/api/cloud/stack/image/doc.go new file mode 100644 index 0000000..025f3af --- /dev/null +++ b/pkg/api/cloud/stack/image/doc.go @@ -0,0 +1,2 @@ +// Package image represents a cloud stack images operations under the `/cloud/stack/image` API endpoint. +package image diff --git a/pkg/api/cloud/stack/image/get.go b/pkg/api/cloud/stack/image/get.go new file mode 100644 index 0000000..c676ab8 --- /dev/null +++ b/pkg/api/cloud/stack/image/get.go @@ -0,0 +1,24 @@ +package image + +import ( + "context" + "errors" + + "github.com/sitehostnz/gosh/pkg/models" +) + +// GetImageByCode returns a image by code. +func (s *Client) GetImageByCode(ctx context.Context, request GetRequest) (response models.Image, err error) { + images, err := s.ListImages(ctx) + if err != nil { + return response, err + } + + for _, image := range images.Return { + if image.Code == request.Code { + return image, nil + } + } + + return response, errors.New("image not found") +} diff --git a/pkg/api/cloud/stack/image/list_all.go b/pkg/api/cloud/stack/image/list_all.go new file mode 100644 index 0000000..3ac443d --- /dev/null +++ b/pkg/api/cloud/stack/image/list_all.go @@ -0,0 +1,21 @@ +package image + +import ( + "context" +) + +// ListImages returns a list of images. +func (s *Client) ListImages(ctx context.Context) (response GetResponse, err error) { + uri := "cloud/stack/image/list_all.json" + + req, err := s.client.NewRequest("GET", uri, "") + if err != nil { + return response, err + } + + if err := s.client.Do(ctx, req, &response); err != nil { + return response, err + } + + return response, nil +} diff --git a/pkg/api/cloud/stack/image/models.go b/pkg/api/cloud/stack/image/models.go new file mode 100644 index 0000000..d85cc8d --- /dev/null +++ b/pkg/api/cloud/stack/image/models.go @@ -0,0 +1,19 @@ +package image + +import ( + "github.com/sitehostnz/gosh/pkg/api" +) + +type ( + // Client is a Service to work with API. + Client struct { + client *api.Client + } +) + +// New is an initialisation function. +func New(c *api.Client) *Client { + return &Client{ + client: c, + } +} diff --git a/pkg/api/cloud/stack/image/request.go b/pkg/api/cloud/stack/image/request.go new file mode 100644 index 0000000..2018648 --- /dev/null +++ b/pkg/api/cloud/stack/image/request.go @@ -0,0 +1,8 @@ +package image + +type ( + // GetRequest is the request to get a image by code. + GetRequest struct { + Code string `json:"code"` + } +) diff --git a/pkg/api/cloud/stack/image/response.go b/pkg/api/cloud/stack/image/response.go new file mode 100644 index 0000000..bd7edbe --- /dev/null +++ b/pkg/api/cloud/stack/image/response.go @@ -0,0 +1,11 @@ +package image + +import "github.com/sitehostnz/gosh/pkg/models" + +type ( + // GetResponse is the response that returns a images list. + GetResponse struct { + Return []models.Image `json:"return"` + models.APIResponse + } +) diff --git a/pkg/api/cloud/stack/request.go b/pkg/api/cloud/stack/request.go index e0d467d..4bdfee5 100644 --- a/pkg/api/cloud/stack/request.go +++ b/pkg/api/cloud/stack/request.go @@ -2,6 +2,13 @@ package stack import "github.com/sitehostnz/gosh/pkg/models" +const ( + // ImageProviderSiteHost is the default image provider. + ImageProviderSiteHost ImageProviderName = "sitehost" + // ImageProviderCustom is a custom image provider (client image). + ImageProviderCustom ImageProviderName = "custom" +) + type ( // ListRequest represents a listing request for stacks on a server. ListRequest struct { @@ -24,6 +31,20 @@ type ( EnvironmentVariables []models.EnvironmentVariable } + // ImageProviderName is a type for image providers. + ImageProviderName string + + // AddRequestWithImage represents the construction / setup of a new cloud stack. + AddRequestWithImage struct { + ServerName string `json:"server_name"` + Name string `json:"name"` + Label string `json:"label"` + EnableSSL int `json:"enable_ssl"` + ImageProvider ImageProviderName + ImageCode string `json:"image_code"` + EnvironmentVariables []models.EnvironmentVariable + } + // StopStartRestartRequest is a request to start, restart or stop a cloud stack/container. StopStartRestartRequest struct { ServerName string `json:"server_name"` diff --git a/pkg/models/docker.go b/pkg/models/docker.go new file mode 100644 index 0000000..b6d20e3 --- /dev/null +++ b/pkg/models/docker.go @@ -0,0 +1,57 @@ +package models + +type ( + // DockerCompose represents a docker-compose.yml file. + // See https://docs.docker.com/compose/compose-file/ + + // Service represents a service in the docker-compose.yml file. + Service struct { + ContainerName string `yaml:"container_name"` + Environment []string `yaml:"environment"` + Expose []string `yaml:"expose"` + Image string `yaml:"image"` + Labels []string `yaml:"labels"` + Restart string `yaml:"restart"` + Volumes []string `yaml:"volumes"` + } + + // Networks represents the networks in the docker-compose.yml file. + Networks struct { + Default DefaultNetwork `yaml:"default"` + } + + // DefaultNetwork represents the default network in the docker-compose.yml file. + DefaultNetwork struct { + External ExternalNetwork `yaml:"external"` + } + + // ExternalNetwork represents the external network in the docker-compose.yml file. + ExternalNetwork struct { + Name string `yaml:"name"` + } + + // DockerCompose represents a docker-compose.yml file. + DockerCompose struct { + Version string `yaml:"version"` + Services map[string]Service `yaml:"services"` + Networks Networks `yaml:"networks"` + } + + // GenerateDockerComposeRequest represents the construction / setup of a docker compose. + GenerateDockerComposeRequest struct { + Name string `json:"name"` + Label string `json:"label"` + RegistryPath string `json:"registry_path"` + ImageCode string `json:"image_code"` + } + + // BuildDockerCompose represents the construction / setup of a docker compose. + BuildDockerCompose struct { + Name string `json:"name"` + Label string `json:"label"` + Image string `json:"image"` + Type string `json:"type"` + Ports []string `yaml:"ports"` + Volumes []string `yaml:"volumes"` + } +) diff --git a/pkg/models/image.go b/pkg/models/image.go index 623fc33..f8a1773 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -1,30 +1,82 @@ package models -import "github.com/sitehostnz/gosh/pkg/utils" +import ( + "bytes" + "encoding/json" +) type ( // Image represents an image in the cloud images api. Image struct { - ID string `json:"id"` - ClientID string `json:"client_id"` - Label string `json:"label"` - Code string `json:"code"` - Version string `json:"version"` - Labels string `json:"labels"` - Changelog string `json:"changelog"` - DateAdded string `json:"date_added"` - DateUpdated string `json:"date_updated"` - IsPublic utils.MaybeBool `json:"is_public"` - IsMissing utils.MaybeBool `json:"is_missing"` - ProjectID string `json:"project_id"` - RegistryID string `json:"registry_id"` - ForkedFrom string `json:"forked_from"` - Pending string `json:"pending"` - ClientName string `json:"client_name"` - ImageType string `json:"image_type"` - RegistryURL string `json:"registry_url"` - VersionCount int `json:"version_count"` - ContainerCount int `json:"container_count"` - BuildStatus string `json:"build_status"` + ID string `json:"id"` + ClientID string `json:"client_id"` + Label string `json:"label"` + Code string `json:"code"` + Labels struct { + NzSitehostImageVolumes struct { + Source any `json:"source"` + Hash string `json:"hash"` + Volumes map[string]Volume `json:"volumes"` + } `json:"nz.sitehost.image.volumes"` + NzSitehostImageProvider string `json:"nz.sitehost.image.provider"` + NzSitehostImageType string `json:"nz.sitehost.image.type"` + NzSitehostImageLabel string `json:"nz.sitehost.image.label"` + NzSitehostImagePorts PortList `json:"nz.sitehost.image.ports"` + } `json:"labels"` + Changelog string `json:"changelog"` + DateAdded string `json:"date_added"` + DateUpdated string `json:"date_updated"` + IsPublic string `json:"is_public"` + IsMissing string `json:"is_missing"` + ProjectID string `json:"project_id"` + RegistryID string `json:"registry_id"` + ForkedFrom any `json:"forked_from"` + Pending any `json:"pending"` + Versions []struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + ImageID string `json:"image_id"` + Version string `json:"version"` + Labels string `json:"labels"` + DateAdded string `json:"date_added"` + DateUpdated string `json:"date_updated"` + IsMissing string `json:"is_missing"` + ForceConfig string `json:"force_config"` + BuildID string `json:"build_id"` + BuildStatus string `json:"build_status"` + Pending string `json:"pending"` + } `json:"versions"` + RegistryPath string `json:"registry_path,omitempty"` + } + + // PortList represents a port list in the cloud images api. + PortList map[string]Port + + // Port represents a port in the cloud images api. + Port struct { + Protocol string `json:"protocol"` + Publish bool `json:"publish"` + Exposed bool `json:"exposed"` + } + + // Volume represents a volume in the cloud images api. + Volume struct { + Dest string `json:"dest"` + Mode string `json:"mode"` } ) + +// UnmarshalJSON implements the json.Unmarshaler interface to fix the port list format. +func (p *PortList) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte("[]")) { + *p = make(map[string]Port) + return nil + } + var m map[string]Port + err := json.Unmarshal(data, &m) + if err != nil { + return err + } + *p = m + return nil +} diff --git a/pkg/utils/dockercompose/docker.go b/pkg/utils/dockercompose/docker.go new file mode 100644 index 0000000..6d37383 --- /dev/null +++ b/pkg/utils/dockercompose/docker.go @@ -0,0 +1,93 @@ +package dockercompose + +import ( + "context" + + "github.com/sitehostnz/gosh/pkg/api" + "github.com/sitehostnz/gosh/pkg/api/cloud/stack/image" + "github.com/sitehostnz/gosh/pkg/models" + "gopkg.in/yaml.v2" +) + +// GenerateDockerCompose generates a docker compose file for a stack. +func GenerateDockerCompose(ctx context.Context, client *api.Client, request models.GenerateDockerComposeRequest) (dockerCompose string, err error) { + // Get the image + i := image.New(client) + image, err := i.GetImageByCode(ctx, image.GetRequest{Code: request.ImageCode}) + if err != nil { + return dockerCompose, err + } + + // Latest version + imageLastVersion := image.Versions[len(image.Versions)-1].Version + + // create volumes and ports + volumes := []string{} + for folder, volume := range image.Labels.NzSitehostImageVolumes.Volumes { + volumes = append(volumes, "/data/docker0/"+image.Labels.NzSitehostImageType+"/"+request.Name+"/"+folder+":"+volume.Dest+":"+volume.Mode) + } + + ports := []string{} + + for port, portInfo := range image.Labels.NzSitehostImagePorts { + if portInfo.Exposed { + ports = append(ports, port+"/"+portInfo.Protocol) + } + } + + compose, err := buildDockerCompose(models.BuildDockerCompose{ + Name: request.Name, + Label: request.Label, + Image: request.RegistryPath + "/" + image.Code + ":" + imageLastVersion, + Type: image.Labels.NzSitehostImageType, + Ports: ports, + Volumes: volumes, + }) + if err != nil { + return dockerCompose, err + } + + return compose, nil +} + +func buildDockerCompose(request models.BuildDockerCompose) (dockerCompose string, err error) { + // Create docker compose file + compose := models.DockerCompose{ + Version: "2.1", + Services: map[string]models.Service{ + request.Name: { + ContainerName: request.Name, + Environment: []string{ + "VIRTUAL_HOST=" + request.Label, + "CERT_NAME=" + request.Label, + }, + Expose: request.Ports, + Image: request.Image, + Labels: []string{ + "nz.sitehost.container.website.vhosts=" + request.Label, + "nz.sitehost.container.image_update=True", + "nz.sitehost.container.label=" + request.Label, + "nz.sitehost.container.type=" + request.Type, + "nz.sitehost.container.monitored=True", + "nz.sitehost.container.backup_disable=False", + }, + Restart: "unless-stopped", + Volumes: request.Volumes, + }, + }, + Networks: models.Networks{ + Default: models.DefaultNetwork{ + External: models.ExternalNetwork{ + Name: "infra_default", + }, + }, + }, + } + + composeYaml, err := yaml.Marshal(&compose) + if err != nil { + return dockerCompose, err + } + + return string(composeYaml), nil +} diff --git a/pkg/utils/dockercompose/docs.go b/pkg/utils/dockercompose/docs.go new file mode 100644 index 0000000..d41c4a5 --- /dev/null +++ b/pkg/utils/dockercompose/docs.go @@ -0,0 +1,2 @@ +// Package dockercompose the utility to create a dockercompose file. +package dockercompose