diff --git a/cli/cmd/collector_inspect.go b/cli/cmd/collector_inspect.go index a0d4962ab..966fe4a96 100644 --- a/cli/cmd/collector_inspect.go +++ b/cli/cmd/collector_inspect.go @@ -29,7 +29,7 @@ func (r *runners) collectorInspect(cmd *cobra.Command, args []string) error { collector, err := r.api.GetCollector(r.appID, id) if err != nil { if err == platformclient.ErrNotFound { - return fmt.Errorf("No such collector %d", id) + return fmt.Errorf("no such collector %s", id) } return err } diff --git a/cli/cmd/installer.go b/cli/cmd/installer.go new file mode 100644 index 000000000..2abb4ee28 --- /dev/null +++ b/cli/cmd/installer.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func (r *runners) InitInstallerCommand(parent *cobra.Command) *cobra.Command { + installerCommand := &cobra.Command{ + Use: "installer", + Short: "Manage Kubernetes installers", + Long: `The installers command allows vendors to create, display, modify and promote kurl.sh specs for managing the installation of Kubernetes.`, + } + parent.AddCommand(installerCommand) + + return installerCommand +} diff --git a/cli/cmd/installer_create.go b/cli/cmd/installer_create.go new file mode 100644 index 000000000..b08f5ff13 --- /dev/null +++ b/cli/cmd/installer_create.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "fmt" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "io/ioutil" +) + +func (r *runners) InitInstallerCreate(parent *cobra.Command) { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new installer spec", + Long: `Create a new installer spec by providing YAML configuration for a https://kurl.sh cluster.`, + } + + parent.AddCommand(cmd) + + cmd.Flags().StringVar(&r.args.createInstallerYaml, "yaml", "", "The YAML config for this installer. Use '-' to read from stdin. Cannot be used with the `yaml-file` falg.") + cmd.Flags().StringVar(&r.args.createInstallerYamlFile, "yaml-file", "", "The file name with YAML config for this installer. Cannot be used with the `yaml` flag.") + cmd.Flags().StringVar(&r.args.createInstallerPromote, "promote", "", "Channel name or id to promote this installer to") + cmd.Flags().BoolVar(&r.args.createInstallerPromoteEnsureChannel, "ensure-channel", false, "When used with --promote , will create the channel if it doesn't exist") + + cmd.RunE = r.installerCreate +} + +func (r *runners) installerCreate(_ *cobra.Command, _ []string) error { + if r.appType != "kots" { + return errors.Errorf("Installer specs are only supported for KOTS applications, app %q has type %q", r.appID, r.appType) + } + + if r.args.createInstallerYaml == "" && + r.args.createInstallerYamlFile == "" { + return errors.New("one of --yaml, --yaml-file is required") + } + + // can't ensure a channel if you didn't pass one + if r.args.createInstallerPromoteEnsureChannel && r.args.createInstallerPromote == "" { + return errors.New("cannot use the flag --ensure-channel without also using --promote ") + } + + if r.args.createInstallerYaml != "" && r.args.createInstallerYamlFile != "" { + return errors.New("only one of --yaml or --yaml-file may be specified") + } + + if r.args.createInstallerYaml == "-" { + bytes, err := ioutil.ReadAll(r.stdin) + if err != nil { + return errors.Wrap(err, "read from stdin") + } + r.args.createInstallerYaml = string(bytes) + } + + if r.args.createInstallerYamlFile != "" { + bytes, err := ioutil.ReadFile(r.args.createInstallerYamlFile) + if err != nil { + return errors.Wrap(err, "read file yaml") + } + r.args.createInstallerYaml = string(bytes) + } + + // if the --promote param was used make sure it identifies exactly one + // channel before proceeding + var promoteChanID string + if r.args.createInstallerPromote != "" { + var err error + promoteChanID, err = r.getOrCreateChannelForPromotion( + r.args.createInstallerPromote, + r.args.createInstallerPromoteEnsureChannel, + ) + if err != nil { + return errors.Wrapf(err, "get or create channel %q for promotion", promoteChanID) + } + } + + installerSpec, err := r.api.CreateInstaller(r.appID, r.appType, r.args.createInstallerYaml) + if err != nil { + return errors.Wrap(err, "create installer") + } + + if _, err := fmt.Fprintf(r.w, "SEQUENCE: %d\n", installerSpec.Sequence); err != nil { + return errors.Wrap(err, "print sequence to r.w") + } + r.w.Flush() + + // don't send a version label as its not really meaningful + noVersionLabel := "" + + if promoteChanID != "" { + if err := r.api.PromoteInstaller( + r.appID, + r.appType, + installerSpec.Sequence, + promoteChanID, + noVersionLabel, + ); err != nil { + return errors.Wrap(err, "promote installer") + } + + // ignore error since operation was successful + fmt.Fprintf(r.w, "Channel %s successfully set to release %d\n", promoteChanID, installerSpec.Sequence) + r.w.Flush() + } + + return nil +} diff --git a/cli/cmd/installer_ls.go b/cli/cmd/installer_ls.go new file mode 100644 index 000000000..041bb5135 --- /dev/null +++ b/cli/cmd/installer_ls.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/replicatedhq/replicated/cli/print" + "github.com/spf13/cobra" +) + +func (r *runners) InitInstallerList(parent *cobra.Command) { + cmd := &cobra.Command{ + Use: "ls", + Short: "List an app's Kubernetes Installers", + Long: "List an app's https://kurl.sh Kubernetes Installers", + } + + parent.AddCommand(cmd) + cmd.RunE = r.installerList +} + +func (r *runners) installerList(_ *cobra.Command, _ []string) error { + installers, err := r.api.ListInstallers(r.appID, r.appType) + if err != nil { + return err + } + + return print.Installers(r.w, installers) +} diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index fa59215bd..6124e532b 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -42,7 +42,7 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) { cmd.RunE = r.releaseCreate } -func (r *runners) releaseCreate(cmd *cobra.Command, args []string) error { +func (r *runners) releaseCreate(_ *cobra.Command, _ []string) error { if r.args.createReleaseYaml == "" && r.args.createReleaseYamlFile == "" && r.args.createReleaseYamlDir == "" { @@ -92,7 +92,10 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) error { var promoteChanID string if r.args.createReleasePromote != "" { var err error - promoteChanID, err = r.getOrCreateChannelForPromotion(r.args.createReleasePromoteEnsureChannel) + promoteChanID, err = r.getOrCreateChannelForPromotion( + r.args.createReleasePromote, + r.args.createReleasePromoteEnsureChannel, + ) if err != nil { return errors.Wrapf(err, "get or create channel %q for promotion", promoteChanID) } @@ -129,18 +132,19 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) error { return nil } -func (r *runners) getOrCreateChannelForPromotion(createIfAbsent bool) (string, error) { +func (r *runners) getOrCreateChannelForPromotion(channelName string, createIfAbsent bool) (string, error) { description := "" // todo: do we want a flag for the desired channel description channel, err := r.api.GetChannelByName( r.appID, r.appType, - r.args.createReleasePromote, + channelName, description, createIfAbsent, - ); if err != nil { - return "", errors.Wrapf(err, "get-or-create channel %q", r.args.createReleasePromote) + ) + if err != nil { + return "", errors.Wrapf(err, "get-or-create channel %q", channelName) } return channel.ID, nil diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 7a52846a3..d9cb9e449 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -26,6 +26,7 @@ var appSlugOrID string var apiToken string var platformOrigin = "https://api.replicated.com/vendor" var graphqlOrigin = "https://g.replicated.com/graphql" +var kurlDotSHOrigin = "https://kurl.sh" func init() { originFromEnv := os.Getenv("REPLICATED_API_ORIGIN") @@ -141,6 +142,10 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i runCmds.InitCustomersLSCommand(customersCmd) runCmds.InitCustomersCreateCommand(customersCmd) + installerCmd := runCmds.InitInstallerCommand(runCmds.rootCmd) + runCmds.InitInstallerCreate(installerCmd) + runCmds.InitInstallerList(installerCmd) + runCmds.rootCmd.SetUsageTemplate(rootCmdUsageTmpl) prerunCommand := func(cmd *cobra.Command, args []string) error { @@ -150,16 +155,22 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i return errors.New("Please provide your API token") } } + + // allow override + if os.Getenv("KURL_SH_ORIGIN") != "" { + kurlDotSHOrigin = os.Getenv("KURL_SH_ORIGIN") + } + platformAPI := platformclient.NewHTTPClient(platformOrigin, apiToken) runCmds.platformAPI = platformAPI shipAPI := shipclient.NewGraphQLClient(graphqlOrigin, apiToken) runCmds.shipAPI = shipAPI - kotsAPI := kotsclient.NewGraphQLClient(graphqlOrigin, apiToken) + kotsAPI := kotsclient.NewGraphQLClient(graphqlOrigin, apiToken, kurlDotSHOrigin) runCmds.kotsAPI = kotsAPI - commonAPI := client.NewClient(platformOrigin, graphqlOrigin, apiToken) + commonAPI := client.NewClient(platformOrigin, graphqlOrigin, apiToken, kurlDotSHOrigin) runCmds.api = commonAPI if appSlugOrID == "" { @@ -200,6 +211,7 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i collectorsCmd.PersistentPreRunE = prerunCommand entitlementsCmd.PersistentPreRunE = prerunCommand customersCmd.PersistentPreRunE = prerunCommand + installerCmd.PersistentPreRunE = prerunCommand runCmds.rootCmd.AddCommand(Version()) diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index dde121830..ca1c83fd6 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -57,7 +57,7 @@ type runnerArgs struct { createReleasePromoteVersion string createReleasePromoteEnsureChannel bool lintReleaseYamlDir string - lintReleaseFailOn string + lintReleaseFailOn string releaseOptional bool releaseNotes string releaseVersion string @@ -82,4 +82,9 @@ type runnerArgs struct { customerCreateChannel string customerCreateEnsureChannel bool customerCreateExpiryDuration time.Duration + + createInstallerYaml string + createInstallerYamlFile string + createInstallerPromote string + createInstallerPromoteEnsureChannel bool } diff --git a/cli/print/installers.go b/cli/print/installers.go new file mode 100644 index 000000000..1d50f95cd --- /dev/null +++ b/cli/print/installers.go @@ -0,0 +1,40 @@ +package print + +import ( + "github.com/replicatedhq/replicated/pkg/types" + "strings" + "text/tabwriter" + "text/template" +) + +var installersTmplSrc = `SEQUENCE CREATED ACTIVE_CHANNELS +{{ range . -}} +{{ .Sequence }} {{ time .CreatedAt.Time }} {{ .ActiveChannels }} +{{ end }}` + +var installersTmpl = template.Must(template.New("Installers").Funcs(funcs).Parse(installersTmplSrc)) + +func Installers(w *tabwriter.Writer, appReleases []types.InstallerSpec) error { + rs := make([]map[string]interface{}, len(appReleases)) + + for i, r := range appReleases { + // join active channel names like "Stable,Unstable" + activeChans := make([]string, len(r.ActiveChannels)) + for j, activeChan := range r.ActiveChannels { + activeChans[j] = activeChan.Name + } + activeChansField := strings.Join(activeChans, ",") + + rs[i] = map[string]interface{}{ + "Sequence": r.Sequence, + "CreatedAt": r.CreatedAt, + "ActiveChannels": activeChansField, + } + } + + if err := installersTmpl.Execute(w, rs); err != nil { + return err + } + + return w.Flush() +} diff --git a/cli/test/cli_test.go b/cli/test/cli_test.go index ab483fd64..2a38bbfc7 100644 --- a/cli/test/cli_test.go +++ b/cli/test/cli_test.go @@ -1,12 +1,16 @@ package test import ( + "os" "testing" . "github.com/onsi/ginkgo" ) func TestCLI(t *testing.T) { + if os.Getenv("SKIP_INTEGRATION_TESTING") != "" { + return + } RunSpecs(t, "CLI Suite") } diff --git a/client/client.go b/client/client.go index ba6ed60ec..2e8555bb9 100644 --- a/client/client.go +++ b/client/client.go @@ -12,11 +12,11 @@ type Client struct { KotsClient *kotsclient.GraphQLClient } -func NewClient(platformOrigin string, graphqlOrigin string, apiToken string) Client { +func NewClient(platformOrigin string, graphqlOrigin string, apiToken string, kurlOrigin string) Client { client := Client{ PlatformClient: platformclient.NewHTTPClient(platformOrigin, apiToken), ShipClient: shipclient.NewGraphQLClient(graphqlOrigin, apiToken), - KotsClient: kotsclient.NewGraphQLClient(graphqlOrigin, apiToken), + KotsClient: kotsclient.NewGraphQLClient(graphqlOrigin, apiToken, kurlOrigin), } return client @@ -40,4 +40,3 @@ func (c *Client) GetAppType(appID string) (string, error) { return "", err } - diff --git a/client/installer.go b/client/installer.go new file mode 100644 index 000000000..693456048 --- /dev/null +++ b/client/installer.go @@ -0,0 +1,45 @@ +package client + +import ( + "github.com/pkg/errors" + "github.com/replicatedhq/replicated/pkg/types" +) + +func (c *Client) CreateInstaller(appId string, appType string, yaml string) (*types.InstallerSpec, error) { + if appType == "platform" { + return nil, errors.Errorf("Kubernetes Installers are not supported for platform applications") + } else if appType == "ship" { + return nil, errors.Errorf("Kubernetes Installers are not supported for platfrom applications") + } else if appType == "kots" { + return c.KotsClient.CreateInstaller(appId, yaml) + } + + return nil, errors.New("unknown app type") +} + +func (c *Client) ListInstallers(appId string, appType string) ([]types.InstallerSpec, error) { + + if appType == "platform" { + return nil, errors.Errorf("Kubernetes Installers are not supported for platform applications") + } else if appType == "ship" { + return nil, errors.Errorf("Kubernetes Installers are not supported for platform applications") + } else if appType == "kots" { + return c.KotsClient.ListInstallers(appId) + } + + return nil, errors.New("unknown app type") + +} + +func (c *Client) PromoteInstaller(appId string, appType string, sequence int64, channelID string, versionLabel string) error { + if appType == "platform" { + return errors.Errorf("Kubernetes Installers are not supported for platform applications") + } else if appType == "ship" { + return errors.Errorf("Kubernetes Installers are not supported for ship applications") + } else if appType == "kots" { + return c.KotsClient.PromoteInstaller(appId, sequence, channelID, versionLabel) + } + + return errors.New("unknown app type") + +} diff --git a/pkg/kotsclient/app.go b/pkg/kotsclient/app.go index 8908c57f1..4e51f9133 100644 --- a/pkg/kotsclient/app.go +++ b/pkg/kotsclient/app.go @@ -31,7 +31,7 @@ type KotsApp struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` - Channels []*KotsAppChannelData `json: "channels"` + Channels []*KotsAppChannelData `json:"channels"` } const listAppsQuery = ` diff --git a/pkg/kotsclient/client.go b/pkg/kotsclient/client.go index d8fc880e6..c1222126d 100644 --- a/pkg/kotsclient/client.go +++ b/pkg/kotsclient/client.go @@ -15,11 +15,15 @@ type ChannelOptions struct { // Client communicates with the Replicated Vendor GraphQL API. type GraphQLClient struct { - GraphQLClient *graphql.Client + GraphQLClient *graphql.Client + KurlDotSHAddress string } -func NewGraphQLClient(origin string, apiKey string) *GraphQLClient { - c := &GraphQLClient{GraphQLClient: graphql.NewClient(origin, apiKey)} +func NewGraphQLClient(origin string, apiKey string, kurlDotSHAddress string) *GraphQLClient { + c := &GraphQLClient{ + GraphQLClient: graphql.NewClient(origin, apiKey), + KurlDotSHAddress: kurlDotSHAddress, + } return c } @@ -27,4 +31,3 @@ func NewGraphQLClient(origin string, apiKey string) *GraphQLClient { func (c *GraphQLClient) ExecuteRequest(requestObj graphql.Request, deserializeTarget interface{}) error { return c.GraphQLClient.ExecuteRequest(requestObj, deserializeTarget) } - diff --git a/pkg/kotsclient/installer.go b/pkg/kotsclient/installer.go new file mode 100644 index 000000000..4660811ee --- /dev/null +++ b/pkg/kotsclient/installer.go @@ -0,0 +1,178 @@ +package kotsclient + +import ( + "fmt" + "github.com/pkg/errors" + "github.com/replicatedhq/replicated/pkg/graphql" + "github.com/replicatedhq/replicated/pkg/types" + "io/ioutil" + "net/http" + "strings" +) + +const kotsListInstallers = ` +query allKotsAppInstallers($appId: ID!) { + allKotsAppInstallers(appId: $appId) { + appId + kurlInstallerId + sequence + yaml + created + channels { + id + name + currentVersion + numReleases + } + isInstallerNotEditable + } +} ` + +type GraphQLResponseListInstallers struct { + Data *InstallersDataWrapper `json:"data,omitempty"` + Errors []graphql.GQLError `json:"errors,omitempty"` +} + +type InstallersDataWrapper struct { + Installers []types.InstallerSpec `json:"allKotsAppInstallers"` +} + +func (c *GraphQLClient) ListInstallers(appID string) ([]types.InstallerSpec, error) { + response := GraphQLResponseListInstallers{} + + request := graphql.Request{ + Query: kotsListInstallers, + + Variables: map[string]interface{}{ + "appId": appID, + }, + } + + if err := c.ExecuteRequest(request, &response); err != nil { + return nil, errors.Wrap(err, "execute gql request") + } + + return response.Data.Installers, nil +} + +const kotsCreateInstaller = ` +mutation createKotsAppInstaller($appId: ID!, $kurlInstallerId: ID!, $yaml: String!) { + createKotsAppInstaller(appId: $appId, kurlInstallerId: $kurlInstallerId, yaml: $yaml) { + appId + kurlInstallerId + sequence + created + } +}` + + +type GraphQLResponseCreateInstaller struct { + Data *CreateInstallerDataWrapper `json:"data,omitempty"` + Errors []graphql.GQLError `json:"errors,omitempty"` +} + +type CreateInstallerDataWrapper struct { + Installer *types.InstallerSpec `json:"createKotsAppInstaller"` +} + +func (c *GraphQLClient) CreateInstaller(appId string, yaml string) (*types.InstallerSpec, error) { + + // post yaml to kurl.sh + installerURL, err := c.CreateKurldotSHInstaller(yaml) + if err != nil { + return nil, errors.Wrap(err, "create kurl installer") + } + + trimmed := strings.TrimLeft(installerURL, "htps:/") + parts := strings.Split(trimmed, "/") + if len(parts) != 2 { + return nil, errors.Errorf("expected exactly two parts of %q, found %d", trimmed, len(parts)) + } + + installerKurlHash := parts[1] + installer, err := c.CreateVendorInstaller(appId, yaml, installerKurlHash) + if err != nil { + return nil, errors.Wrapf(err, "create vendor installer for kurl hash %q", installerKurlHash) + } + + return installer, nil +} + +func (c *GraphQLClient) CreateKurldotSHInstaller(yaml string) (string, error) { + bodyReader := strings.NewReader(yaml) + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/installer", c.KurlDotSHAddress), bodyReader) + if err != nil { + return "", errors.Wrap(err, "create request") + } + + req.Header.Set("Content-Type", "text/yaml") + + client := http.DefaultClient + + resp, err := client.Do(req) + if err != nil { + return "", errors.Wrap(err, "do request") + + } + + responseBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "read response body") + } + + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("unexpected status code %d, body was %s", resp.StatusCode, responseBody) + } + + return string(responseBody), nil +} + +func (c *GraphQLClient) CreateVendorInstaller(appID string, yaml string, kurlInstallerID string) (*types.InstallerSpec, error) { + response := GraphQLResponseCreateInstaller{} + + request := graphql.Request{ + Query: kotsCreateInstaller, + + Variables: map[string]interface{}{ + "appId": appID, + "yaml": yaml, + "kurlInstallerId": kurlInstallerID, + }, + } + + if err := c.ExecuteRequest(request, &response); err != nil { + return nil, errors.Wrap(err, "execute gql request") + } + + return response.Data.Installer, nil +} + +const kotsPromoteInstaller = ` +mutation promoteKotsInstaller($appId: ID!, $sequence: Int, $channelIds: [String], $versionLabel: String!) { + promoteKotsInstaller(appId: $appId, sequence: $sequence, channelIds: $channelIds, versionLabel: $versionLabel) { + kurlInstallerId + } +}` + +func (c *GraphQLClient) PromoteInstaller(appID string, sequence int64, channelID string, versionLabel string) error { + response := graphql.ResponseErrorOnly{} + + request := graphql.Request{ + Query: kotsPromoteInstaller, + + Variables: map[string]interface{}{ + "appId": appID, + "sequence": sequence, + "channelIds": []string{channelID}, + "versionLabel": versionLabel, + }, + } + + if err := c.ExecuteRequest(request, &response); err != nil { + return errors.Wrap(err, "execute gql request") + } + + return nil + +} diff --git a/pkg/kotsclient/kotsclient_test.go b/pkg/kotsclient/kotsclient_test.go index e61bf9c10..87f7399e7 100644 --- a/pkg/kotsclient/kotsclient_test.go +++ b/pkg/kotsclient/kotsclient_test.go @@ -13,6 +13,9 @@ var ( ) func TestMain(m *testing.M) { + if os.Getenv("SKIP_PACT_TESTING") != "" { + return + } pact = createPact() pact.Setup(true) diff --git a/pkg/kotsclient/release_test.go b/pkg/kotsclient/release_test.go index d7ce46024..4baabae3f 100644 --- a/pkg/kotsclient/release_test.go +++ b/pkg/kotsclient/release_test.go @@ -17,12 +17,16 @@ func Test_ListKotsReleasesActual(t *testing.T) { uri, err := url.Parse(u) assert.Nil(t, err) + d := &graphql.Client{ GQLServer: uri, Token: "all-kots-releases-read-write-token", } - c := &GraphQLClient{GraphQLClient: d} + c := &GraphQLClient{ + GraphQLClient: d, + KurlDotSHAddress: "", + } releases, err := c.ListReleases("all-kots-releases") assert.Nil(t, err) diff --git a/pkg/platformclient/client_test.go b/pkg/platformclient/client_test.go index 5d24beebd..1f4d67713 100644 --- a/pkg/platformclient/client_test.go +++ b/pkg/platformclient/client_test.go @@ -13,6 +13,9 @@ var ( ) func TestMain(m *testing.M) { + if os.Getenv("SKIP_PACT_TESTING") != "" { + return + } pact = createPact() pact.Setup(true) diff --git a/pkg/shipclient/shipclient_test.go b/pkg/shipclient/shipclient_test.go index 38b62d61b..4861591a6 100644 --- a/pkg/shipclient/shipclient_test.go +++ b/pkg/shipclient/shipclient_test.go @@ -13,6 +13,10 @@ var ( ) func TestMain(m *testing.M) { + if os.Getenv("SKIP_PACT_TESTING") != "" { + return + } + pact = createPact() pact.Setup(true) diff --git a/pkg/types/installer.go b/pkg/types/installer.go new file mode 100644 index 000000000..b3d1f3b3d --- /dev/null +++ b/pkg/types/installer.go @@ -0,0 +1,15 @@ +package types + +import ( + "github.com/replicatedhq/replicated/pkg/util" +) + +type InstallerSpec struct { + AppID string `json:"appId"` + KurlInstallerID string `json:"kurlInstallerID"` + Sequence int64 `json:"sequence"` + YAML string `json:"yaml"` + ActiveChannels []Channel `json:"channels"` + CreatedAt util.Time `json:"created"` + Immutable bool `json:"isInstallerNotEditable"` +} diff --git a/pkg/util/parseTime.go b/pkg/util/parseTime.go index 95ed78259..af456dc30 100644 --- a/pkg/util/parseTime.go +++ b/pkg/util/parseTime.go @@ -1,6 +1,7 @@ package util import ( + "github.com/pkg/errors" "strings" "time" ) @@ -14,3 +15,20 @@ func ParseTime(ts string) (time.Time, error) { ts = strings.TrimSuffix(ts, "+0000 (UTC)") return time.Parse("Mon Jan 02 2006 15:04:05 MST", ts) } + +type Time struct { + time.Time `json:",inline"` +} + +func (t *Time) UnmarshalJSON(b []byte) error { + + // strings come with quotes on them + ts := strings.Trim(string(b), "\"") + + parsed, err := ParseTime(ts) + if err != nil { + return errors.Wrap(err, "parse time") + } + *t = Time{parsed} + return nil +} diff --git a/pkg/util/parseTime_test.go b/pkg/util/parseTime_test.go index 336e3bd61..59753e7bc 100644 --- a/pkg/util/parseTime_test.go +++ b/pkg/util/parseTime_test.go @@ -1,6 +1,7 @@ package util import ( + "encoding/json" "reflect" "testing" "time" @@ -37,3 +38,39 @@ func TestParseTime(t *testing.T) { }) } } + +func TestParseTimeType(t *testing.T) { + testTime, err := time.Parse("Mon Jan 02 2006 15:04:05 MST", "Wed May 22 2019 23:01:51 GMT") + if err != nil { + panic(err) + } + + tests := []struct { + name string + json string + want Time + wantErr bool + }{ + { + name: "parse time", + json: `{"time": "Wed May 22 2019 23:01:51 GMT+0000 (UTC)"}`, + want: Time{Time: testTime}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := map[string]Time{ + "time": {}, + } + err := json.Unmarshal([]byte(tt.json), &target) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTime() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(target["time"], tt.want) { + t.Errorf("ParseTime() = %v, want %v", target, tt.want) + } + }) + } +}