Skip to content

Commit

Permalink
feat: support for multi channel licenses (#4767)
Browse files Browse the repository at this point in the history
* feat: support for multi channel licenses

---------

Co-authored-by: Salah Al Saleh <[email protected]>
  • Loading branch information
pandemicsyn and sgalsaleh authored Aug 8, 2024
1 parent 141e0b8 commit f02b6e7
Show file tree
Hide file tree
Showing 76 changed files with 1,380 additions and 277 deletions.
1 change: 1 addition & 0 deletions .github/actions/kots-e2e/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ runs:
- name: execute suite "${{ inputs.test-focus }}"
env:
TESTIM_ACCESS_TOKEN: ${{ inputs.testim-access-token }}
REPLICATED_API_TOKEN: ${{ inputs.replicated-api-token }}
KOTS_NAMESPACE: ${{ inputs.kots-namespace }}
run: |
make -C e2e test \
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/build-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,40 @@ jobs:
kots-dockerhub-username: '${{ secrets.E2E_DOCKERHUB_USERNAME }}'
kots-dockerhub-password: '${{ secrets.E2E_DOCKERHUB_PASSWORD }}'

validate-change-channel:
runs-on: ubuntu-20.04
needs: [ enable-tests, can-run-ci, build-kots, build-kotsadm, build-e2e, build-kurl-proxy, build-migrations, push-minio, push-rqlite ]
strategy:
fail-fast: false
matrix:
cluster: [
{distribution: kind, version: v1.28.0}
]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: download e2e deps
uses: actions/download-artifact@v4
with:
name: e2e
path: e2e/bin/
- run: docker load -i e2e/bin/e2e-deps.tar
- run: chmod +x e2e/bin/*
- name: download kots binary
uses: actions/download-artifact@v4
with:
name: kots
path: bin/
- run: chmod +x bin/*
- uses: ./.github/actions/kots-e2e
with:
test-focus: 'Change Channel'
kots-namespace: 'change-channel'
k8s-distribution: ${{ matrix.cluster.distribution }}
k8s-version: ${{ matrix.cluster.version }}
replicated-api-token: '${{ secrets.C11Y_MATRIX_TOKEN }}'
kots-dockerhub-username: '${{ secrets.E2E_DOCKERHUB_USERNAME }}'
kots-dockerhub-password: '${{ secrets.E2E_DOCKERHUB_PASSWORD }}'

validate-minimal-rbac-override:
runs-on: ubuntu-20.04
Expand Down Expand Up @@ -4130,6 +4164,7 @@ jobs:
- validate-backup-and-restore
- validate-no-required-config
- validate-config
- validate-change-channel
# non-testim tests
- validate-minimal-rbac
- validate-minimal-rbac-override
Expand Down
10 changes: 10 additions & 0 deletions cmd/kots/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types"
"github.com/replicatedhq/kots/pkg/kotsutil"
"github.com/replicatedhq/kots/pkg/kurl"
kotslicense "github.com/replicatedhq/kots/pkg/license"
"github.com/replicatedhq/kots/pkg/logger"
"github.com/replicatedhq/kots/pkg/metrics"
preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types"
Expand Down Expand Up @@ -162,6 +163,14 @@ func InstallCmd() *cobra.Command {
}()

upstream := pull.RewriteUpstream(args[0])
preferredChannelSlug, err := extractPreferredChannelSlug(upstream)
if err != nil {
return errors.Wrap(err, "failed to extract preferred channel slug")
}
license, err = kotslicense.VerifyAndUpdateLicense(log, license, preferredChannelSlug, isAirgap)
if err != nil {
return errors.Wrap(err, "failed to verify and update license")
}

namespace := v.GetString("namespace")

Expand Down Expand Up @@ -278,6 +287,7 @@ func InstallCmd() *cobra.Command {
IncludeMinio: v.GetBool("with-minio"),
IncludeMinioSnapshots: v.GetBool("with-minio"),
StrictSecurityContext: v.GetBool("strict-security-context"),
RequestedChannelSlug: preferredChannelSlug,

RegistryConfig: *registryConfig,

Expand Down
56 changes: 29 additions & 27 deletions cmd/kots/cli/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (

"github.com/pkg/errors"
"github.com/replicatedhq/kots/pkg/k8sutil"
"github.com/replicatedhq/kots/pkg/kotsutil"
kotslicense "github.com/replicatedhq/kots/pkg/license"
"github.com/replicatedhq/kots/pkg/logger"
"github.com/replicatedhq/kots/pkg/pull"
registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
"github.com/replicatedhq/kotskinds/client/kotsclientset/scheme"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -43,11 +44,13 @@ func PullCmd() *cobra.Command {
}
}

appSlug, err := getAppSlugForPull(args[0], v.GetString("license-file"))
license, err := getLicense(v)
if err != nil {
return errors.Wrap(err, "failed to determine app slug")
return errors.Wrap(err, "failed to get license")
}

appSlug := getAppSlugForPull(args[0], license)

namespace, err := getNamespaceOrDefault(v.GetString("namespace"))
if err != nil {
return errors.Wrap(err, "failed to get namespace")
Expand Down Expand Up @@ -98,13 +101,30 @@ func PullCmd() *cobra.Command {
}

upstream := pull.RewriteUpstream(args[0])
renderDir, err := pull.Pull(upstream, pullOptions)
preferredChannelSlug, err := extractPreferredChannelSlug(upstream)
if err != nil {
return errors.Wrap(err, "failed to pull")
return errors.Wrap(err, "failed to extract preferred channel slug")
}

log := logger.NewCLILogger(cmd.OutOrStdout())
log.Initialize()

// If we are passed a multi-channel license, verify that the requested channel is in the license
// so that we can warn the user immediately if it is not.
license, err = kotslicense.VerifyAndUpdateLicense(log, license, preferredChannelSlug, false)
if err != nil {
return errors.Wrap(err, "failed to verify and update license")
}
pullOptions.AppSelectedChannelID, err = kotsutil.FindChannelIDInLicense(preferredChannelSlug, license)
if err != nil { // should never happen since we just verified the channel
return errors.Wrap(err, "failed to find channel ID in license")
}

renderDir, err := pull.Pull(upstream, pullOptions)
if err != nil {
return errors.Wrap(err, "failed to pull")
}

log.Info("Kubernetes application files created in %s", renderDir)
if len(v.GetStringSlice("downstream")) == 0 {
log.Info("To deploy, run kubectl apply -k %s", path.Join(renderDir, "overlays", "midstream"))
Expand Down Expand Up @@ -146,28 +166,10 @@ func PullCmd() *cobra.Command {
return cmd
}

func getAppSlugForPull(uri string, licenseFile string) (string, error) {
func getAppSlugForPull(uri string, license *kotsv1beta1.License) string {
appSlug := strings.Split(uri, "/")[0]
if licenseFile == "" {
return appSlug, nil
}

licenseData, err := os.ReadFile(licenseFile)
if err != nil {
return "", errors.Wrap(err, "failed to read license file")
}

decode := scheme.Codecs.UniversalDeserializer().Decode
decoded, gvk, err := decode(licenseData, nil, nil)
if err != nil {
return "", errors.Wrap(err, "unable to decode license file")
}

if gvk.Group != "kots.io" || gvk.Version != "v1beta1" || gvk.Kind != "License" {
return "", errors.New("not an application license")
if license == nil {
return appSlug
}

license := decoded.(*kotsv1beta1.License)

return license.Spec.AppSlug, nil
return license.Spec.AppSlug
}
18 changes: 18 additions & 0 deletions cmd/kots/cli/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

"github.com/pkg/errors"
"github.com/replicatedhq/kots/pkg/replicatedapp"
"github.com/replicatedhq/kots/pkg/util"
)

Expand Down Expand Up @@ -51,3 +52,20 @@ func splitEndpointAndNamespace(endpoint string) (string, string) {
}
return registryEndpoint, registryNamespace
}

func extractPreferredChannelSlug(upstreamURI string) (string, error) {
u, err := url.ParseRequestURI(upstreamURI)
if err != nil {
return "", errors.Wrap(err, "failed to parse uri")
}

replicatedUpstream, err := replicatedapp.ParseReplicatedURL(u)
if err != nil {
return "", errors.Wrap(err, "failed to parse replicated url")
}

if replicatedUpstream.Channel != nil {
return *replicatedUpstream.Channel, nil
}
return "stable", nil
}
49 changes: 49 additions & 0 deletions cmd/kots/cli/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,52 @@ func Test_getHostFromEndpoint(t *testing.T) {
})
}
}

func Test_extractPreferredChannelSlug(t *testing.T) {
type args struct {
upstreamURI string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
"no channel",
args{
upstreamURI: "replicated://app-slug",
},
"stable", // default channel
false,
},
{
"with channel",
args{
upstreamURI: "replicated://app-slug/channel",
},
"channel",
false,
},
{
"invalid uri",
args{
upstreamURI: "junk",
},
"",
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := extractPreferredChannelSlug(tt.args.upstreamURI)
if (err != nil) != tt.wantErr {
t.Errorf("extractPreferredChannelSlug() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("extractPreferredChannelSlug() = %v, want %v", got, tt.want)
}
})
}
}
1 change: 1 addition & 0 deletions e2e/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ test:
docker run --rm -i --net host \
-e TESTIM_ACCESS_TOKEN \
-v $(BIN_DIR)/e2e.test:/usr/local/bin/e2e.test \
-e REPLICATED_API_TOKEN \
-v $(KOTS_BIN_DIR)/kots:/usr/local/bin/kots \
-v $(KOTS_BIN_DIR)/kots:/usr/local/bin/kubectl-kots \
-v $(PLAYWRIGHT_DIR)/playwright-report:/playwright/playwright-report \
Expand Down
1 change: 1 addition & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ var _ = Describe("E2E", func() {
Entry(nil, inventory.MultiAppTest()),
Entry(nil, inventory.NewSupportBundle()),
Entry(nil, inventory.NewGitOps()),
Entry(nil, inventory.NewChangeChannel()),
)

})
Expand Down
10 changes: 10 additions & 0 deletions e2e/inventory/inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ func NewGitOps() Test {
}
}

func NewChangeChannel() Test {
return Test{
ID: "change-channel",
Name: "Change Channel",
Namespace: "change-channel",
AppSlug: "change-channel",
UpstreamURI: "change-channel/automated",
}
}

func SetupRegressionTest(kubectlCLI *kubectl.CLI) TestimParams {
cmd := kubectlCLI.Command(
context.Background(),
Expand Down
29 changes: 29 additions & 0 deletions e2e/playwright/tests/change-channel/license.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
apiVersion: kots.io/v1beta1
kind: License
metadata:
name: github-action
spec:
appSlug: change-channel
channelID: 2k6j62KPRQLjO0tF9zZB6zJJukg
channelName: Automated
channels:
- channelID: 2k6j62KPRQLjO0tF9zZB6zJJukg
channelName: Automated
channelSlug: automated
endpoint: https://replicated.app
isDefault: true
customerName: github-action
endpoint: https://replicated.app
entitlements:
expires_at:
description: License Expiration
signature: {}
title: Expiration
value: ""
valueType: String
isKotsInstallEnabled: true
isNewKotsUiEnabled: true
licenseID: 2k6jempcB6aQyddcIEBSTj0Bvvv
licenseSequence: 16
licenseType: trial
signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pWjJsMGFIVmlMV0ZqZEdsdmJpSjlMQ0p6Y0dWaklqcDdJbXhwWTJWdWMyVkpSQ0k2SWpKck5tcGxiWEJqUWpaaFVYbGtaR05KUlVKVFZHb3dRbloyZGlJc0lteHBZMlZ1YzJWVWVYQmxJam9pZEhKcFlXd2lMQ0pqZFhOMGIyMWxjazVoYldVaU9pSm5hWFJvZFdJdFlXTjBhVzl1SWl3aVlYQndVMngxWnlJNkltTm9ZVzVuWlMxamFHRnVibVZzSWl3aVkyaGhibTVsYkVsRUlqb2lNbXMyYWpZeVMxQlNVVXhxVHpCMFJqbDZXa0kyZWtwS2RXdG5JaXdpWTJoaGJtNWxiRTVoYldVaU9pSkJkWFJ2YldGMFpXUWlMQ0pqYUdGdWJtVnNjeUk2VzNzaVkyaGhibTVsYkVsRUlqb2lNbXMyYWpZeVMxQlNVVXhxVHpCMFJqbDZXa0kyZWtwS2RXdG5JaXdpWTJoaGJtNWxiRk5zZFdjaU9pSmhkWFJ2YldGMFpXUWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrRjFkRzl0WVhSbFpDSXNJbWx6UkdWbVlYVnNkQ0k2ZEhKMVpTd2laVzVrY0c5cGJuUWlPaUpvZEhSd2N6b3ZMM0psY0d4cFkyRjBaV1F1WVhCd0luMWRMQ0pzYVdObGJuTmxVMlZ4ZFdWdVkyVWlPakUyTENKbGJtUndiMmx1ZENJNkltaDBkSEJ6T2k4dmNtVndiR2xqWVhSbFpDNWhjSEFpTENKbGJuUnBkR3hsYldWdWRITWlPbnNpWlhod2FYSmxjMTloZENJNmV5SjBhWFJzWlNJNklrVjRjR2x5WVhScGIyNGlMQ0prWlhOamNtbHdkR2x2YmlJNklreHBZMlZ1YzJVZ1JYaHdhWEpoZEdsdmJpSXNJblpoYkhWbElqb2lJaXdpZG1Gc2RXVlVlWEJsSWpvaVUzUnlhVzVuSWl3aWMybG5ibUYwZFhKbElqcDdmWDE5TENKcGMwNWxkMHR2ZEhOVmFVVnVZV0pzWldRaU9uUnlkV1VzSW1selMyOTBjMGx1YzNSaGJHeEZibUZpYkdWa0lqcDBjblZsZlgwPSIsImlubmVyU2lnbmF0dXJlIjoiZXlKc2FXTmxibk5sVTJsbmJtRjBkWEpsSWpvaWRsRmpjWGt6T1hRellYQnRabWhTVFdoR01HdE5jblp2YlhST1pGZDBaR3Q1UW1zNVZWbHBaM2MzT0ZKMU56Z3Jaa0V5ZW1aVVRGTXJNV1JMUkRCcVNWcG5SbkZhYkRCRlJVaHJWbmRWUld0bE5IWk9RV2QyY1VsdWJrcDJXVGxNWWpKNGVUTk1NMDFQY1VjMk1GZERZV1l3VGxkUVpWTm9jbTVHYldSSmVXcG1SRFpOTUhsWVVUWndZVkpaZVU0MWFsaEdPVUZVY0dWVU5VSXpWVnBYVkdwb1R6QTJPVk5GYVdkUGEyeExjRlYzU3pZclRGbENPV2xEVWk5bWMyNXdkbU5vV0ROYU1HSjVSSGhtZEZnMFF6Rk1XWEF4VVc5MWRITjZNMkl2TVZZMWRXOUdMemd6YlZGUVRsQXplVEZrT1hadVFYazFlV1VyWVd4cE1HTm1RalZuYmpoTFYybERkRTFsVHk5aGNHRnFZbTlVTmpoTVdGUXZRbWhTUnpaQlJVTTBaWFp4Vkd4aVNYTXhZVTFOWW0wM2VIcGFlVFo2Y2twNVFWRXJjVzA1TmxWVVpGUjRiekJ4VUdKRFJrVndWSGhyYzJKQlBUMGlMQ0p3ZFdKc2FXTkxaWGtpT2lJdExTMHRMVUpGUjBsT0lGQlZRa3hKUXlCTFJWa3RMUzB0TFZ4dVRVbEpRa2xxUVU1Q1oydHhhR3RwUnpsM01FSkJVVVZHUVVGUFEwRlJPRUZOU1VsQ1EyZExRMEZSUlVFeFFWRmFRa2xEZW5wemRXTnVXV1JNVlM5YU1GeHVNSGsxTVc5U1EzaGxNVzFRWTBvNVUxbzRORlJqZUVScFVVZFNTRzluV2pZMVQyWklWV3g1V21Sb09FeEpOSFpYZFRKRGJVVnpkbVppV1VOS05VWlRTRnh1UlhSeVkzWnhURTlUWTJVemNsaHZhR2RNVmpkNVRtaFZNa3cxUm1vMWFuRXhkelU0WjBWbGVHTXJZbTUxVVdvME16TXJOR2s0VkZWTk1tNUZVVzFKY1Z4dU1tZHNOa1JxUWk5Sk1YWnNVMjQxTmtvdk1IVmhSWHBaVjFBck4ySkhlbTEzT0dNeWQycEJhRTFyUkZJck5USlBRM2hHY1dGaFExbDBaemROTDBWbE5seHVaWFIzWVRGNFREUlNSMlJCSzNkTFYzaDRWa1VyYjAxMlluQjROMXA1WWxJdldERjBRazlvZWtaSVdISjBaa2RCYm5CWE1sQkRjbVJ4Um1oMWVsWlZMMXh1TUhWSVRuVk1URkV5ZVVaemNFVjJUa1V4YlVKRFpqZHpUR2hNY205M1RVRlBWRnAxWkZJNGFtMXJTMnhJWlROT2MzUnlUekl2ZVVadWN6bHZWRnBoTWx4dWJYZEpSRUZSUVVKY2JpMHRMUzB0UlU1RUlGQlZRa3hKUXlCTFJWa3RMUzB0TFZ4dUlpd2lhMlY1VTJsbmJtRjBkWEpsSWpvaVpYbEtlbUZYWkhWWldGSXhZMjFWYVU5cFNsQmhhbVJ4VWtaT2MwMHljSEJhVjFKMVlsZDRiV05GTVhsVFJXUnRUVzVCTldJd1VUUmFSMFY0WTBka2VrOVhUbFpoVjJSRllXdHNTbEpXYUhwbGF6VlBWakZPVkU1SVJrMWhhWFJMVlhwc2JXSXhWa1pTTUhoNFUzazROV0l6VGxKV2JtUXhVbTVXTldNeFVsSmllWFJEWWpGQk0wMUhVbHBqYkc4d1lUSTViRk13UlhoaFdHUmFZbnBhV0dGVWJFcFphMk15U3pCUmQxTkhjRWRrYlhnelVtMVZNbUpUZEV0a2JsSlBZak5yTldSR1ZuRlRXRkpUVmpGSmVtRnJNVmRWUkZwUllsVTRkMWRYVWt4UFNFMTZWMWRhVldOdVRrOWlibWgxV2pGUk1WWlZUVEZhVkVKM1UxVTVWMXBYVGpOVlZrSkhWa2hqZGxSc1dsQmlSVnBxWVRCS05rOUlRbHBsVjBaTVpESlZNbE5YZEhWT1EzUndVbXBDVTJSVVVsQk9NalZDWVRBNGNsRnNhR3BUYlZZeldYcGtRMVZVUms5bFJXUmhVV3hGTkU1VmR6VmtSM2N4WTBaa2NsTkZUbmRMTWxadFVqSkpNV1J1UmpKU1NFcHRWVlZhZEdNelJrWlhVemxIV1dwYVVWZFliSE5OYlU1RVZucHNSVmx1YUhWWFIzUnJZbFpqTVU5VVdsWlhiVkV6VDFadmNsZFlVbmRNTVdSdFpWZEtlVlpHYnpOaVIxSnpaRzVzYjJNd05XaGpTRlpYWTFST1VrNXNiSFZhTVdoMFRXNXdOVnBIWXpsUVUwbHpTVzFrYzJJeVNtaGlSWFJzWlZWc2EwbHFiMmxaYlZKc1dsUlZNazVVV1hkWk1scHBUa1JPYWs5WFNYbFBSMHB0VDFSb2JGbFhUbWhhYlVVeVRrUlphV1pSUFQwaWZRPT0ifQ==
58 changes: 58 additions & 0 deletions e2e/playwright/tests/change-channel/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test';
import { login, uploadLicense } from '../shared';

const CUSTOMER_ID = '2k6jemHbYgZFqtwgyjqiVfjRQqi';
const APP_ID = '2k6j65t0STtrZ1emyP5PUqBIQ23';
const AUTOMATED_CHANNEL_ID = '2k6j62KPRQLjO0tF9zZB6zJJukg';
const ALTERNATE_CHANNEL_ID = '2k6j61j49IPyDyQlbmRZJsxy3TP';

test('change channel', async ({ page }) => {
test.slow();
await changeChannel(AUTOMATED_CHANNEL_ID);
await login(page);
await uploadLicense(page, expect);
await page.getByRole('button', { name: 'Deploy' }).click();
await expect(page.locator('#app')).toContainText('Automated');
await changeChannel(ALTERNATE_CHANNEL_ID);

await page.getByText('Sync license').click();

await expect(page.getByLabel('Next step')).toContainText('License synced', { timeout: 10000 });
await page.getByRole('button', { name: 'Ok, got it!' }).click();

await expect(page.locator('#app')).toContainText('Alternate');
await expect(page.locator('#app')).toContainText('1.0.3', { timeout: 10000 });
await expect(page.locator('#app')).toContainText('Upstream Update', { timeout: 10000 });

await page.getByRole('button', { name: 'Deploy', exact: true }).click();
await page.getByRole('button', { name: 'Yes, Deploy' }).click();

await expect(page.locator('#app')).toContainText('Currently deployed version', { timeout: 15000 });
await expect(page.getByText('v1.0.0')).not.toBeVisible();
await expect(page.getByText('1.0.3')).toBeVisible();
});

async function changeChannel(channelId: string) {
await fetch(`https://api.replicated.com/vendor/v3/customer/${CUSTOMER_ID}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': process.env.REPLICATED_API_TOKEN,
},
body: JSON.stringify({
"app_id": APP_ID,
"name": "github-action", // customer name
"channels": [
{
"channel_id": channelId,
"pinned_channel_sequence": null,
"is_default_for_customer": true
}
]
})
}).then(response => {
if (!response.ok) {
throw new Error(`Unexpected status code: ${response.status}`);
}
});
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
github.com/replicatedhq/embedded-cluster-kinds v1.4.7
github.com/replicatedhq/kotskinds v0.0.0-20240621084729-1eb1e3eac6f2
github.com/replicatedhq/kotskinds v0.0.0-20240718194123-1018dd404e95
github.com/replicatedhq/kurlkinds v1.5.0
github.com/replicatedhq/troubleshoot v0.95.1
github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq
Expand Down
Loading

0 comments on commit f02b6e7

Please sign in to comment.