From aaa2496609894da2f9dadafbaeb0dfb452b84569 Mon Sep 17 00:00:00 2001 From: Ricardo Maraschini Date: Tue, 5 Dec 2023 00:10:55 +0100 Subject: [PATCH] feat: add support for embedded cluster updates TBD --- go.mod | 1 + go.sum | 3 + migrations/tables/app_version.yaml | 2 + pkg/embeddedcluster/util.go | 72 +++++++++++++++ pkg/handlers/embedded_cluster_update.go | 118 ++++++++++++++++++++++++ pkg/handlers/handlers.go | 4 + pkg/handlers/interface.go | 2 + pkg/kotsutil/kots.go | 30 ++++++ pkg/store/kotsstore/version_store.go | 28 +++++- 9 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 pkg/handlers/embedded_cluster_update.go diff --git a/go.mod b/go.mod index 32a3a8cfac..5c134b28c6 100644 --- a/go.mod +++ b/go.mod @@ -184,6 +184,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.4 // indirect github.com/go-logr/logr v1.3.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.2.4 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.21.4 // indirect github.com/go-openapi/errors v0.20.4 // indirect diff --git a/go.sum b/go.sum index 095ffbcdbc..5b2b7359f3 100644 --- a/go.sum +++ b/go.sum @@ -647,6 +647,7 @@ github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7 github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -1856,6 +1857,7 @@ go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= @@ -1872,6 +1874,7 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/migrations/tables/app_version.yaml b/migrations/tables/app_version.yaml index 7ee1a00947..f24b8116d2 100644 --- a/migrations/tables/app_version.yaml +++ b/migrations/tables/app_version.yaml @@ -71,3 +71,5 @@ spec: type: text - name: branding_archive type: text + - name: embeddedcluster_config + type: text diff --git a/pkg/embeddedcluster/util.go b/pkg/embeddedcluster/util.go index 1ed8576092..889cda5e27 100644 --- a/pkg/embeddedcluster/util.go +++ b/pkg/embeddedcluster/util.go @@ -1,9 +1,13 @@ package embeddedcluster import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "sort" + "time" embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-operator/api/v1beta1" "github.com/replicatedhq/kots/pkg/k8sutil" @@ -13,6 +17,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" kbclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" ) const configMapName = "embedded-cluster-config" @@ -58,6 +64,23 @@ func ClusterID(client kubernetes.Interface) (string, error) { return configMap.Data["embedded-cluster-id"], nil } +// RequiresUpgrade returns true if the provided configuration differs from the latest active configuration. +func RequiresUpgrade(ctx context.Context, newcfg embeddedclusterv1beta1.ConfigSpec) (bool, error) { + curcfg, err := ClusterConfig(ctx) + if err != nil { + return false, fmt.Errorf("failed to get current cluster config: %w", err) + } + serializedCur, err := json.Marshal(curcfg) + if err != nil { + return false, err + } + serializedNew, err := json.Marshal(newcfg) + if err != nil { + return false, err + } + return !bytes.Equal(serializedCur, serializedNew), nil +} + // ClusterConfig will get the list of installations, find the latest installation, and get that installation's config func ClusterConfig(ctx context.Context) (*embeddedclusterv1beta1.ConfigSpec, error) { clientConfig, err := k8sutil.GetClusterConfig() @@ -67,6 +90,10 @@ func ClusterConfig(ctx context.Context) (*embeddedclusterv1beta1.ConfigSpec, err scheme := runtime.NewScheme() embeddedclusterv1beta1.AddToScheme(scheme) + k8slogger := zap.New(func(o *zap.Options) { + o.DestWriter = io.Discard + }) + log.SetLogger(k8slogger) kbClient, err := kbclient.New(clientConfig, kbclient.Options{ Scheme: scheme, @@ -89,3 +116,48 @@ func ClusterConfig(ctx context.Context) (*embeddedclusterv1beta1.ConfigSpec, err latest := installationList.Items[0] return latest.Spec.Config, nil } + +// StartClusterUpdate will create a new installation with the provided config. +func StartClusterUpdate(ctx context.Context, newcfg embeddedclusterv1beta1.ConfigSpec) error { + clientConfig, err := k8sutil.GetClusterConfig() + if err != nil { + return fmt.Errorf("failed to get cluster config: %w", err) + } + scheme := runtime.NewScheme() + embeddedclusterv1beta1.AddToScheme(scheme) + k8slogger := zap.New(func(o *zap.Options) { + o.DestWriter = io.Discard + }) + log.SetLogger(k8slogger) + kbClient, err := kbclient.New(clientConfig, kbclient.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to get kubebuilder client: %w", err) + } + var installationList embeddedclusterv1beta1.InstallationList + err = kbClient.List(ctx, &installationList, &kbclient.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list installations: %w", err) + } + sort.Slice(installationList.Items, func(i, j int) bool { + return installationList.Items[i].ObjectMeta.CreationTimestamp.After(installationList.Items[j].ObjectMeta.CreationTimestamp.Time) + }) + if len(installationList.Items) == 0 { + return fmt.Errorf("no installations found") + } + latest := installationList.Items[0] + newins := embeddedclusterv1beta1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: time.Now().Format("20060102150405"), + }, + Spec: embeddedclusterv1beta1.InstallationSpec{ + ClusterID: latest.Spec.ClusterID, + MetricsBaseURL: latest.Spec.MetricsBaseURL, + AirGap: latest.Spec.AirGap, + Config: &newcfg, + }, + } + if err := kbClient.Create(ctx, &newins); err != nil { + return fmt.Errorf("failed to create installation: %w", err) + } + return nil +} diff --git a/pkg/handlers/embedded_cluster_update.go b/pkg/handlers/embedded_cluster_update.go new file mode 100644 index 0000000000..a7255e9dc1 --- /dev/null +++ b/pkg/handlers/embedded_cluster_update.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/replicatedhq/kots/pkg/embeddedcluster" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/store" +) + +// EmbeddedClusterUpdateCheckResponse is the response of the cluster update check. Contains the app +// slug the the verison to upgrade to. +type EmbeddedClusterUpdateCheckResponse struct { + Slug string `json:"app"` + Version string `json:"version"` +} + +// EmbeddedClusterUpdateCheck is a handler func that checks if an update is available for the embedded +// cluster and returns the version to upgrade to if so. +func (h *Handler) EmbeddedClusterUpdateCheck(w http.ResponseWriter, r *http.Request) { + errout := func(err error) { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + } + ctx := r.Context() + appSlug := mux.Vars(r)["appSlug"] + client, err := k8sutil.GetClientset() + if err != nil { + errout(err) + return + } + if isEmbeddedCluster, err := embeddedcluster.IsEmbeddedCluster(client); err != nil { + errout(err) + return + } else if !isEmbeddedCluster { + w.WriteHeader(http.StatusNoContent) + return + } + app, err := store.GetStore().GetAppFromSlug(appSlug) + if err != nil { + errout(err) + return + } + version, err := store.GetStore().GetAppVersion(app.ID, app.CurrentSequence) + if err != nil { + errout(err) + return + } + if version.KOTSKinds == nil || version.KOTSKinds.EmbeddedClusterConfig == nil { + w.WriteHeader(http.StatusNoContent) + return + } + upgrade, err := embeddedcluster.RequiresUpgrade(ctx, version.KOTSKinds.EmbeddedClusterConfig.Spec) + if err != nil { + errout(err) + return + } + if !upgrade { + w.WriteHeader(http.StatusNoContent) + return + } + JSON(w, http.StatusOK, EmbeddedClusterUpdateCheckResponse{ + Slug: app.Slug, + Version: version.KOTSKinds.EmbeddedClusterConfig.Spec.Version, + }) +} + +func (h *Handler) StartClusterUpdate(w http.ResponseWriter, r *http.Request) { + errout := func(err error) { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + } + ctx := r.Context() + appSlug := mux.Vars(r)["appSlug"] + client, err := k8sutil.GetClientset() + if err != nil { + errout(err) + return + } + if isEmbeddedCluster, err := embeddedcluster.IsEmbeddedCluster(client); err != nil { + errout(err) + return + } else if !isEmbeddedCluster { + w.WriteHeader(http.StatusNoContent) + return + } + app, err := store.GetStore().GetAppFromSlug(appSlug) + if err != nil { + errout(err) + return + } + version, err := store.GetStore().GetAppVersion(app.ID, app.CurrentSequence) + if err != nil { + errout(err) + return + } + if version.KOTSKinds == nil || version.KOTSKinds.EmbeddedClusterConfig == nil { + w.WriteHeader(http.StatusNoContent) + return + } + upgrade, err := embeddedcluster.RequiresUpgrade(ctx, version.KOTSKinds.EmbeddedClusterConfig.Spec) + if err != nil { + errout(err) + return + } + if !upgrade { + w.WriteHeader(http.StatusNoContent) + return + } + if err := embeddedcluster.StartClusterUpdate(ctx, version.KOTSKinds.EmbeddedClusterConfig.Spec); err != nil { + errout(err) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 0e7d51b55e..cbf79f9606 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -289,6 +289,10 @@ func RegisterSessionAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOT HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetEmbeddedClusterNode)) r.Name("GetEmbeddedClusterRoles").Path("/api/v1/embedded-cluster/roles").Methods("GET"). HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetEmbeddedClusterRoles)) + r.Name("EmbeddedClusterUpdateCheck").Path("/api/v1/embedded-cluster/{appSlug}/updatecheck").Methods("GET"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.EmbeddedClusterUpdateCheck)) + r.Name("EmbeddedClusterStartUpdate").Path("/api/v1/embedded-cluster/{appSlug}/update").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.StartClusterUpdate)) // Prometheus r.Name("SetPrometheusAddress").Path("/api/v1/prometheus").Methods("POST"). diff --git a/pkg/handlers/interface.go b/pkg/handlers/interface.go index d5d02d9e1d..5040aefc1e 100644 --- a/pkg/handlers/interface.go +++ b/pkg/handlers/interface.go @@ -145,6 +145,8 @@ type KOTSHandler interface { GetEmbeddedClusterNodes(w http.ResponseWriter, r *http.Request) GetEmbeddedClusterNode(w http.ResponseWriter, r *http.Request) GetEmbeddedClusterRoles(w http.ResponseWriter, r *http.Request) + EmbeddedClusterUpdateCheck(w http.ResponseWriter, r *http.Request) + StartClusterUpdate(w http.ResponseWriter, r *http.Request) // Prometheus SetPrometheusAddress(w http.ResponseWriter, r *http.Request) diff --git a/pkg/kotsutil/kots.go b/pkg/kotsutil/kots.go index 440a10ebba..eb930cf6b8 100644 --- a/pkg/kotsutil/kots.go +++ b/pkg/kotsutil/kots.go @@ -17,6 +17,7 @@ import ( "github.com/blang/semver" "github.com/pkg/errors" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-operator/api/v1beta1" "github.com/replicatedhq/kots/pkg/archives" "github.com/replicatedhq/kots/pkg/binaries" "github.com/replicatedhq/kots/pkg/buildversion" @@ -51,6 +52,7 @@ func init() { velerov1.AddToScheme(scheme.Scheme) kurlscheme.AddToScheme(scheme.Scheme) applicationv1beta1.AddToScheme(scheme.Scheme) + embeddedclusterv1beta1.AddToScheme(scheme.Scheme) } var ( @@ -105,6 +107,8 @@ type KotsKinds struct { Installer *kurlv1beta1.Installer LintConfig *kotsv1beta1.LintConfig + + EmbeddedClusterConfig *embeddedclusterv1beta1.Config } func IsKotsKind(apiVersion string, kind string) bool { @@ -129,6 +133,10 @@ func IsKotsKind(apiVersion string, kind string) bool { if apiVersion == "kurl.sh/v1beta1" { return true } + // In addition to kotskinds, we exclude the embedded cluster configuration. + if apiVersion == "embeddedcluster.replicated.com/v1beta1" { + return true + } // In addition to kotskinds, we exclude the application crd for now if apiVersion == "app.k8s.io/v1beta1" { return true @@ -448,6 +456,14 @@ func (o KotsKinds) Marshal(g string, v string, k string) (string, error) { } } + if g == "embeddedcluster.replicated.com" && v == "v1beta1" && k == "Config" { + var b bytes.Buffer + if err := s.Encode(o.EmbeddedClusterConfig, &b); err != nil { + return "", errors.Wrap(err, "failed to encode embedded cluster config") + } + return string(b.Bytes()), nil + } + return "", errors.Errorf("unknown gvk %s/%s, Kind=%s", g, v, k) } @@ -528,6 +544,8 @@ func (k *KotsKinds) addKotsKinds(content []byte) error { k.Installer = decoded.(*kurlv1beta1.Installer) case "app.k8s.io/v1beta1, Kind=Application": k.Application = decoded.(*applicationv1beta1.Application) + case "embeddedcluster.replicated.com/v1beta1, Kind=Config": + k.EmbeddedClusterConfig = decoded.(*embeddedclusterv1beta1.Config) } } @@ -913,6 +931,18 @@ func LoadLicenseFromBytes(data []byte) (*kotsv1beta1.License, error) { return obj.(*kotsv1beta1.License), nil } +func LoadEmbeddedClusterConfigFromBytes(data []byte) (*embeddedclusterv1beta1.Config, error) { + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, gvk, err := decode([]byte(data), nil, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to decode embedded cluster config data") + } + if gvk.Group != "embeddedcluster.replicated.com" || gvk.Version != "v1beta1" || gvk.Kind != "Config" { + return nil, errors.Errorf("unexpected GVK: %s", gvk.String()) + } + return obj.(*embeddedclusterv1beta1.Config), nil +} + func LoadConfigValuesFromFile(configValuesFilePath string) (*kotsv1beta1.ConfigValues, error) { configValuesData, err := ioutil.ReadFile(configValuesFilePath) if err != nil { diff --git a/pkg/store/kotsstore/version_store.go b/pkg/store/kotsstore/version_store.go index 0f1ab3612b..27ed91aa4d 100644 --- a/pkg/store/kotsstore/version_store.go +++ b/pkg/store/kotsstore/version_store.go @@ -701,6 +701,11 @@ func (s *KOTSStore) upsertAppVersionRecordStatements(appID string, sequence int6 return nil, errors.Wrap(err, "failed to marshal configvalues spec") } + embeddedClusterConfig, err := kotsKinds.Marshal("embeddedcluster.replicated.com", "v1beta1", "Config") + if err != nil { + return nil, errors.Wrap(err, "failed to marshal configvalues spec") + } + var releasedAt *int64 if kotsKinds.Installation.Spec.ReleasedAt != nil { t := kotsKinds.Installation.Spec.ReleasedAt.Time.Unix() @@ -708,8 +713,8 @@ func (s *KOTSStore) upsertAppVersionRecordStatements(appID string, sequence int6 } query := `insert into app_version (app_id, sequence, created_at, version_label, is_required, release_notes, update_cursor, channel_id, channel_name, upstream_released_at, encryption_key, - supportbundle_spec, analyzer_spec, preflight_spec, app_spec, kots_app_spec, kots_installation_spec, kots_license, config_spec, config_values, backup_spec, identity_spec, branding_archive) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + supportbundle_spec, analyzer_spec, preflight_spec, app_spec, kots_app_spec, kots_installation_spec, kots_license, config_spec, config_values, backup_spec, identity_spec, branding_archive, embeddedcluster_config) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(app_id, sequence) DO UPDATE SET created_at = EXCLUDED.created_at, version_label = EXCLUDED.version_label, @@ -731,7 +736,8 @@ func (s *KOTSStore) upsertAppVersionRecordStatements(appID string, sequence int6 config_values = EXCLUDED.config_values, backup_spec = EXCLUDED.backup_spec, identity_spec = EXCLUDED.identity_spec, - branding_archive = EXCLUDED.branding_archive` + branding_archive = EXCLUDED.branding_archive, + embeddedcluster_config = EXCLUDED.embeddedcluster_config` statements = append(statements, gorqlite.ParameterizedStatement{ Query: query, @@ -759,6 +765,7 @@ func (s *KOTSStore) upsertAppVersionRecordStatements(appID string, sequence int6 backupSpec, identitySpec, base64.StdEncoding.EncodeToString(brandingArchive), + embeddedClusterConfig, }, }) @@ -811,7 +818,7 @@ func (s *KOTSStore) upsertAppDownstreamVersionStatements(appID string, clusterID func (s *KOTSStore) GetAppVersion(appID string, sequence int64) (*versiontypes.AppVersion, error) { db := persistence.MustGetDBSession() - query := `select app_id, sequence, update_cursor, channel_id, version_label, created_at, status, applied_at, kots_installation_spec, kots_app_spec, kots_license from app_version where app_id = ? and sequence = ?` + query := `select app_id, sequence, update_cursor, channel_id, version_label, created_at, status, applied_at, kots_installation_spec, kots_app_spec, kots_license, embeddedcluster_config from app_version where app_id = ? and sequence = ?` rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ Query: query, Arguments: []interface{}{appID, sequence}, @@ -1086,8 +1093,9 @@ func (s *KOTSStore) appVersionFromRow(row gorqlite.QueryResult) (*versiontypes.A var updateCursor gorqlite.NullString var channelID gorqlite.NullString var versionLabel gorqlite.NullString + var embeddedClusterConfig gorqlite.NullString - if err := row.Scan(&v.AppID, &v.Sequence, &updateCursor, &channelID, &versionLabel, &createdAt, &status, &createdAt, &installationSpec, &kotsAppSpec, &licenseSpec); err != nil { + if err := row.Scan(&v.AppID, &v.Sequence, &updateCursor, &channelID, &versionLabel, &createdAt, &status, &createdAt, &installationSpec, &kotsAppSpec, &licenseSpec, &embeddedClusterConfig); err != nil { return nil, errors.Wrap(err, "failed to scan") } @@ -1127,6 +1135,16 @@ func (s *KOTSStore) appVersionFromRow(row gorqlite.QueryResult) (*versiontypes.A } } + if embeddedClusterConfig.Valid && embeddedClusterConfig.String != "" { + config, err := kotsutil.LoadEmbeddedClusterConfigFromBytes([]byte(embeddedClusterConfig.String)) + if err != nil { + return nil, errors.Wrap(err, "failed to read embedded cluster config") + } + if config != nil { + v.KOTSKinds.EmbeddedClusterConfig = config + } + } + v.CreatedOn = createdAt.Time if deployedAt.Valid { v.DeployedAt = &deployedAt.Time