Skip to content

Commit 87f75fb

Browse files
authored
New hidden KOTS command for V3 EC to sync license, download update, update config and deploy in a single operation (#5562)
* New hidden KOTS command for V3 EC to sync license, download update, update config and deploy in a single operation * Handle airgap mode * fix build * pin velero * fix handler tests * address feedback * e2e tests * fix waiting for ready * more fixes * one more try * one more * fix get versions command * one more try * update customer and sort * one more * fix lint errors
1 parent 5cc00ae commit 87f75fb

File tree

18 files changed

+1158
-146
lines changed

18 files changed

+1158
-146
lines changed

.github/workflows/build-test.yaml

Lines changed: 439 additions & 0 deletions
Large diffs are not rendered by default.

cmd/kots/cli/deploy.go

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"strconv"
12+
"strings"
13+
"time"
14+
15+
cursor "github.com/ahmetalpbalkan/go-cursor"
16+
"github.com/pkg/errors"
17+
"github.com/replicatedhq/kots/pkg/auth"
18+
"github.com/replicatedhq/kots/pkg/k8sutil"
19+
"github.com/replicatedhq/kots/pkg/logger"
20+
"github.com/replicatedhq/kots/pkg/upstream"
21+
"github.com/spf13/cobra"
22+
"github.com/spf13/viper"
23+
"k8s.io/client-go/kubernetes"
24+
)
25+
26+
func DeployCmd() *cobra.Command {
27+
cmd := &cobra.Command{
28+
Use: "deploy [appSlug]",
29+
Hidden: true, // Hidden from help
30+
Args: cobra.ExactArgs(1),
31+
PreRun: func(cmd *cobra.Command, args []string) {
32+
viper.BindPFlags(cmd.Flags())
33+
},
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
v := viper.GetViper()
36+
37+
if len(args) < 1 {
38+
cmd.Help()
39+
os.Exit(1)
40+
}
41+
42+
// Validate flag requirements: either airgap-bundle OR (channel-id AND channel-sequence)
43+
license := v.GetString("license")
44+
airgapBundle := v.GetString("airgap-bundle")
45+
channelID := v.GetString("channel-id")
46+
channelSequence := v.GetInt64("channel-sequence")
47+
48+
if airgapBundle == "" && (channelID == "" || channelSequence == 0) {
49+
return errors.New("either --airgap-bundle OR (--channel-id AND --channel-sequence) must be provided")
50+
}
51+
52+
if airgapBundle != "" && license == "" {
53+
return errors.New("license is required in airgap mode")
54+
}
55+
56+
if license != "" && airgapBundle == "" {
57+
return errors.New("license can only be provided in airgap mode")
58+
}
59+
60+
if airgapBundle != "" {
61+
if _, err := os.Stat(airgapBundle); err != nil {
62+
return errors.Wrap(err, "failed to stat airgap bundle")
63+
}
64+
}
65+
66+
fmt.Print(cursor.Hide())
67+
defer fmt.Print(cursor.Show())
68+
69+
log := logger.NewCLILogger(cmd.OutOrStdout())
70+
appSlug := args[0]
71+
namespace, err := getNamespaceOrDefault(v.GetString("namespace"))
72+
if err != nil {
73+
return errors.Wrap(err, "failed to get namespace")
74+
}
75+
76+
clientset, err := k8sutil.GetClientset()
77+
if err != nil {
78+
return errors.Wrap(err, "failed to get clientset")
79+
}
80+
81+
getPodName := func() (string, error) {
82+
return k8sutil.WaitForKotsadm(clientset, namespace, time.Second*5)
83+
}
84+
85+
stopCh := make(chan struct{})
86+
defer close(stopCh)
87+
88+
log.ActionWithoutSpinner("Starting deployment process for %s...", appSlug)
89+
90+
localPort, errChan, err := k8sutil.PortForward(0, 3000, namespace, getPodName, false, stopCh, log)
91+
if err != nil {
92+
return errors.Wrap(err, "failed to start port forwarding")
93+
}
94+
95+
go func() {
96+
select {
97+
case err := <-errChan:
98+
if err != nil {
99+
log.Error(err)
100+
}
101+
case <-stopCh:
102+
}
103+
}()
104+
105+
authSlug, err := auth.GetOrCreateAuthSlug(clientset, namespace)
106+
if err != nil {
107+
return errors.Wrap(err, "failed to get kotsadm auth slug")
108+
}
109+
110+
// Step 1: License Sync
111+
if err := handleLicenseSync(v, appSlug, localPort, authSlug, log); err != nil {
112+
return errors.Wrap(err, "failed to sync license")
113+
}
114+
115+
// Step 2: If airgap bundle, push images first
116+
if airgapBundle != "" {
117+
if err := handleAirgapImagePush(v, clientset, appSlug, log); err != nil {
118+
return errors.Wrap(err, "failed to push airgap images")
119+
}
120+
}
121+
122+
// Step 3: Upstream Update (both online and airgap)
123+
if err := handleUpstreamUpdate(v, appSlug, localPort, authSlug, log); err != nil {
124+
return errors.Wrap(err, "failed to process upstream update")
125+
}
126+
127+
// Step 4: Set Config + Deploy
128+
if err := handleSetConfigAndDeploy(v, appSlug, localPort, authSlug, log); err != nil {
129+
return errors.Wrap(err, "failed to set config and deploy")
130+
}
131+
132+
log.ActionWithoutSpinner("Deployment process completed successfully")
133+
134+
return nil
135+
},
136+
}
137+
138+
cmd.Flags().String("license", "", "path to license file (airgap mode only)")
139+
cmd.Flags().String("channel-id", "", "channel ID")
140+
cmd.Flags().Int64("channel-sequence", 0, "channel sequence")
141+
cmd.Flags().String("airgap-bundle", "", "path to airgap bundle")
142+
cmd.Flags().String("config-values", "", "path to config values file")
143+
cmd.Flags().Bool("skip-preflights", false, "skip preflight checks")
144+
145+
cmd.MarkFlagRequired("config-values")
146+
147+
registryFlags(cmd.Flags())
148+
149+
return cmd
150+
}
151+
152+
func handleLicenseSync(v *viper.Viper, appSlug string, localPort int, authSlug string, log *logger.CLILogger) error {
153+
log.ActionWithoutSpinner("Syncing license...")
154+
155+
licenseData := ""
156+
157+
// Check if license flag was provided
158+
if licenseFilePath := v.GetString("license"); licenseFilePath != "" {
159+
data, err := os.ReadFile(licenseFilePath)
160+
if err != nil {
161+
return errors.Wrap(err, "failed to read license file")
162+
}
163+
licenseData = string(data)
164+
}
165+
166+
requestPayload := map[string]interface{}{
167+
"licenseData": licenseData,
168+
}
169+
170+
requestBody, err := json.Marshal(requestPayload)
171+
if err != nil {
172+
return errors.Wrap(err, "failed to marshal license sync request json")
173+
}
174+
175+
url := fmt.Sprintf("http://localhost:%d/api/v1/app/%s/license", localPort, url.PathEscape(appSlug))
176+
newRequest, err := http.NewRequest("PUT", url, bytes.NewBuffer(requestBody))
177+
if err != nil {
178+
return errors.Wrap(err, "failed to create license sync http request")
179+
}
180+
newRequest.Header.Add("Authorization", authSlug)
181+
newRequest.Header.Add("Content-Type", "application/json")
182+
183+
resp, err := http.DefaultClient.Do(newRequest)
184+
if err != nil {
185+
return errors.Wrap(err, "failed to execute license sync http request")
186+
}
187+
defer resp.Body.Close()
188+
189+
respBody, err := io.ReadAll(resp.Body)
190+
if err != nil {
191+
return errors.Wrap(err, "failed to read license sync server response")
192+
}
193+
194+
response := struct {
195+
Success bool `json:"success"`
196+
Error string `json:"error,omitempty"`
197+
Synced bool `json:"synced"`
198+
}{}
199+
200+
if err := json.Unmarshal(respBody, &response); err != nil && resp.StatusCode == http.StatusOK {
201+
return errors.Wrap(err, "failed to parse license sync response")
202+
}
203+
204+
if resp.StatusCode != http.StatusOK {
205+
if response.Error != "" {
206+
return errors.Errorf("license sync failed: %s", response.Error)
207+
}
208+
return errors.Errorf("license sync failed with status code %d", resp.StatusCode)
209+
}
210+
211+
if response.Synced {
212+
log.ActionWithoutSpinner("License synced successfully")
213+
} else {
214+
log.ActionWithoutSpinner("License already up to date")
215+
}
216+
217+
return nil
218+
}
219+
220+
func handleUpstreamUpdate(v *viper.Viper, appSlug string, localPort int, authSlug string, log *logger.CLILogger) error {
221+
log.ActionWithoutSpinner("Processing upstream update...")
222+
223+
airgapBundle := v.GetString("airgap-bundle")
224+
isAirgap := airgapBundle != ""
225+
226+
var requestBody io.Reader
227+
var contentType string
228+
229+
if isAirgap {
230+
// Create multipart form data for airgap bundle using shared function
231+
var err error
232+
requestBody, contentType, err = upstream.CreateAirgapMultipartRequest(airgapBundle)
233+
if err != nil {
234+
return err
235+
}
236+
} else {
237+
requestBody = strings.NewReader("{}")
238+
contentType = "application/json"
239+
}
240+
241+
urlVals := url.Values{}
242+
if v.GetBool("skip-preflights") {
243+
urlVals.Set("skipPreflights", "true")
244+
}
245+
if channelID := v.GetString("channel-id"); channelID != "" {
246+
urlVals.Set("channelId", channelID)
247+
}
248+
if channelSequence := v.GetInt64("channel-sequence"); channelSequence != 0 {
249+
urlVals.Set("channelSequence", strconv.FormatInt(channelSequence, 10))
250+
}
251+
252+
upstreamURL := fmt.Sprintf("http://localhost:%d/api/v1/app/%s/upstream/update?%s", localPort, url.PathEscape(appSlug), urlVals.Encode())
253+
254+
newRequest, err := http.NewRequest("POST", upstreamURL, requestBody)
255+
if err != nil {
256+
return errors.Wrap(err, "failed to create upstream update http request")
257+
}
258+
newRequest.Header.Add("Authorization", authSlug)
259+
newRequest.Header.Add("Content-Type", contentType)
260+
261+
resp, err := http.DefaultClient.Do(newRequest)
262+
if err != nil {
263+
return errors.Wrap(err, "failed to execute upstream update http request")
264+
}
265+
defer resp.Body.Close()
266+
267+
respBody, err := io.ReadAll(resp.Body)
268+
if err != nil {
269+
return errors.Wrap(err, "failed to read upstream update server response")
270+
}
271+
272+
response := struct {
273+
Success bool `json:"success"`
274+
Error string `json:"error,omitempty"`
275+
}{}
276+
277+
if err := json.Unmarshal(respBody, &response); err != nil && resp.StatusCode == http.StatusOK {
278+
return errors.Wrap(err, "failed to parse upstream update response")
279+
}
280+
281+
if resp.StatusCode != http.StatusOK {
282+
if response.Error != "" {
283+
return errors.Errorf("upstream update failed: %s", response.Error)
284+
}
285+
return errors.Errorf("upstream update failed with status code %d", resp.StatusCode)
286+
}
287+
288+
log.ActionWithoutSpinner("Upstream update processed successfully")
289+
290+
return nil
291+
}
292+
293+
func handleAirgapImagePush(v *viper.Viper, clientset kubernetes.Interface, appSlug string, log *logger.CLILogger) error {
294+
log.ActionWithoutSpinner("Pushing airgap images...")
295+
296+
airgapBundle := v.GetString("airgap-bundle")
297+
298+
registryConfig, err := getRegistryConfig(v, clientset, appSlug)
299+
if err != nil {
300+
return errors.Wrap(err, "failed to get registry config")
301+
}
302+
303+
err = upstream.PushImagesFromAirgapBundle(airgapBundle, *registryConfig)
304+
if err != nil {
305+
return err
306+
}
307+
308+
log.ActionWithoutSpinner("Images pushed successfully")
309+
return nil
310+
}
311+
312+
func handleSetConfigAndDeploy(v *viper.Viper, appSlug string, localPort int, authSlug string, log *logger.CLILogger) error {
313+
log.ActionWithoutSpinner("Setting configuration and deploying...")
314+
315+
// Read config values from file (required flag)
316+
configValues, err := os.ReadFile(v.GetString("config-values"))
317+
if err != nil {
318+
return errors.Wrap(err, "failed to read config values file")
319+
}
320+
321+
requestPayload := map[string]interface{}{
322+
"configValues": configValues,
323+
"deploy": true, // HARDCODED - always deploy
324+
"skipPreflights": v.GetBool("skip-preflights"),
325+
}
326+
327+
requestBody, err := json.Marshal(requestPayload)
328+
if err != nil {
329+
return errors.Wrap(err, "failed to marshal config request json")
330+
}
331+
332+
url := fmt.Sprintf("http://localhost:%d/api/v1/app/%s/config/values", localPort, url.PathEscape(appSlug))
333+
newRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
334+
if err != nil {
335+
return errors.Wrap(err, "failed to create config http request")
336+
}
337+
newRequest.Header.Add("Authorization", authSlug)
338+
newRequest.Header.Add("Content-Type", "application/json")
339+
340+
resp, err := http.DefaultClient.Do(newRequest)
341+
if err != nil {
342+
return errors.Wrap(err, "failed to execute config http request")
343+
}
344+
defer resp.Body.Close()
345+
346+
respBody, err := io.ReadAll(resp.Body)
347+
if err != nil {
348+
return errors.Wrap(err, "failed to read config server response")
349+
}
350+
351+
response := struct {
352+
Error string `json:"error"`
353+
}{}
354+
355+
if err := json.Unmarshal(respBody, &response); err != nil && resp.StatusCode == http.StatusOK {
356+
return errors.Wrap(err, "failed to parse config response")
357+
}
358+
359+
if resp.StatusCode != http.StatusOK {
360+
if resp.StatusCode == http.StatusNotFound {
361+
return errors.Errorf("app with slug %s not found", appSlug)
362+
} else if response.Error != "" {
363+
return errors.New(response.Error)
364+
} else {
365+
return errors.Errorf("config and deploy failed with status code %d", resp.StatusCode)
366+
}
367+
}
368+
369+
log.ActionWithoutSpinner("Configuration set and deployment initiated successfully")
370+
371+
return nil
372+
}

cmd/kots/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func RootCmd() *cobra.Command {
5757
cmd.AddCommand(EnableHACmd())
5858
cmd.AddCommand(UpgradeServiceCmd())
5959
cmd.AddCommand(AirgapUpdateCmd())
60+
cmd.AddCommand(DeployCmd()) // Hidden command
6061

6162
viper.BindPFlags(cmd.Flags())
6263

e2e/scripts/deps.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ main() {
6363
curl -sL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash -e && \
6464
( [ $(id -u) -eq 0 -o "$USE_SUDO" != "true" ] || runAsRoot chown $(id -u):$(id -g) $INSTALL_DIR/helm )
6565

66-
VELERO_RELEASE=$(curl -s "https://api.github.com/repos/vmware-tanzu/velero/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
66+
# VELERO_RELEASE=$(curl -s "https://api.github.com/repos/vmware-tanzu/velero/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
67+
VELERO_RELEASE=v1.16.2 # pin to 1.16.2 as 1.17.0 removed restic support
6768
echo "VELERO_RELEASE=$VELERO_RELEASE"
6869
curl -fsLo velero.tar.gz "https://github.com/vmware-tanzu/velero/releases/download/$VELERO_RELEASE/velero-$VELERO_RELEASE-$OS-$ARCH.tar.gz" \
6970
&& tar xzf velero.tar.gz \

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ require (
433433
github.com/smallstep/pkcs7 v0.1.1 // indirect
434434
github.com/sorairolake/lzip-go v0.3.5 // indirect
435435
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
436+
github.com/stretchr/objx v0.5.2 // indirect
436437
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
437438
github.com/valyala/fastjson v1.6.4 // indirect
438439
github.com/vektah/gqlparser/v2 v2.5.30 // indirect

0 commit comments

Comments
 (0)