spectrum-cli 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chart.js CHANGED
@@ -3,8 +3,9 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { execSync } = require('child_process');
5
5
  const readline = require('readline');
6
- const { logSuccess, logError, execCommand, execSilent, colors } = require('./utils');
6
+ const { logSuccess, logError, execCommand, execSilent, getRemoteUrl, colors } = require('./utils');
7
7
  const { runCommand } = require('./command-executor');
8
+ const { parseGitlabUrl } = require('./token');
8
9
  const {
9
10
  requireGitRepo,
10
11
  requireCleanWorkingTree,
@@ -14,6 +15,7 @@ const {
14
15
  requireRemoteReachable,
15
16
  requireSingleChart,
16
17
  requireTagMissing,
18
+ requireChartChangelogVersion,
17
19
  requireHelmReleaseFiles,
18
20
  requireSingleValuesYaml,
19
21
  requireIngressPathSections,
@@ -459,7 +461,10 @@ function chartVerify(sourcePath) {
459
461
  });
460
462
  }
461
463
 
462
- function chartCreateTag(version) {
464
+ function chartCreateTag(version, options = {}) {
465
+ const force = Boolean(options.force);
466
+ const wait = Boolean(options.wait);
467
+
463
468
  return runCommand({
464
469
  name: 'chart create',
465
470
  checks: [
@@ -477,6 +482,30 @@ function chartCreateTag(version) {
477
482
  }
478
483
  },
479
484
  { name: 'single-chart', run: requireSingleChart },
485
+ {
486
+ name: 'chart-changelog-version',
487
+ run: (ctx) => requireChartChangelogVersion(path.dirname(ctx.chartFilePath), ctx.version)
488
+ },
489
+ {
490
+ name: 'version-not-downgrade',
491
+ run: (ctx) => {
492
+ const latestVersion = getLatestRemoteChartVersion(ctx.chartName);
493
+ if (!latestVersion) {
494
+ return { ok: true };
495
+ }
496
+ if (compareSemver(ctx.version, latestVersion) > 0) {
497
+ return { ok: true, data: { latestChartVersion: latestVersion } };
498
+ }
499
+ if (force) {
500
+ logError('⚠️', 'Версия %s не больше последней опубликованной %s. Продолжаю из-за --force.', ctx.version, latestVersion);
501
+ return { ok: true, data: { latestChartVersion: latestVersion } };
502
+ }
503
+ return {
504
+ ok: false,
505
+ reason: `Версия "${ctx.version}" не больше последней опубликованной "${latestVersion}". Для сознательного отката используйте --force.`
506
+ };
507
+ }
508
+ },
480
509
  {
481
510
  name: 'tag-missing',
482
511
  run: (ctx) => requireTagMissing(`chart-${ctx.chartName}-${ctx.version}`)
@@ -506,7 +535,13 @@ function chartCreateTag(version) {
506
535
  logSuccess('🚀', 'Тег %s отправлен в origin.', tagName);
507
536
  return true;
508
537
  }
509
- }
538
+ },
539
+ ...(wait
540
+ ? [{
541
+ name: 'wait-for-registry',
542
+ run: (ctx) => waitForChartInRegistry(ctx.chartName, ctx.version)
543
+ }]
544
+ : [])
510
545
  ]
511
546
  });
512
547
  }
@@ -583,6 +618,152 @@ function getLatestRemoteChartVersion(chartName) {
583
618
  return versions[versions.length - 1];
584
619
  }
585
620
 
621
+ const HELM_REGISTRY_CHANNEL = 'stable';
622
+ const REGISTRY_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
623
+ const REGISTRY_WAIT_INTERVAL_MS = 15 * 1000;
624
+
625
+ function sleep(ms) {
626
+ return new Promise((resolve) => setTimeout(resolve, ms));
627
+ }
628
+
629
+ function getGitlabProject() {
630
+ const baseUrl = getRemoteUrl();
631
+ if (!baseUrl) {
632
+ return null;
633
+ }
634
+ try {
635
+ return parseGitlabUrl(baseUrl);
636
+ } catch (error) {
637
+ return null;
638
+ }
639
+ }
640
+
641
+ function helmIndexHasChartVersion(indexYamlContent, chartName, version) {
642
+ const escapedChartName = String(chartName || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
643
+ const chartHeadingPattern = new RegExp(`^${escapedChartName}:\\s*(#.*)?$`);
644
+ const lines = String(indexYamlContent || '').split('\n');
645
+ let inEntries = false;
646
+ let inChart = false;
647
+ let entriesIndent = 0;
648
+ let chartIndent = 0;
649
+
650
+ for (const rawLine of lines) {
651
+ const line = rawLine.replace(/\r$/, '');
652
+ const trimmed = line.trim();
653
+ if (!trimmed || trimmed.startsWith('#')) {
654
+ continue;
655
+ }
656
+
657
+ const indent = line.match(/^\s*/)[0].length;
658
+
659
+ if (/^entries:\s*(#.*)?$/.test(trimmed) && indent === 0) {
660
+ inEntries = true;
661
+ inChart = false;
662
+ entriesIndent = indent;
663
+ continue;
664
+ }
665
+
666
+ if (inEntries && indent <= entriesIndent) {
667
+ inEntries = false;
668
+ inChart = false;
669
+ }
670
+
671
+ if (!inEntries) {
672
+ continue;
673
+ }
674
+
675
+ if (chartHeadingPattern.test(trimmed)) {
676
+ inChart = true;
677
+ chartIndent = indent;
678
+ continue;
679
+ }
680
+
681
+ if (inChart && indent <= chartIndent) {
682
+ inChart = false;
683
+ }
684
+
685
+ if (!inChart) {
686
+ continue;
687
+ }
688
+
689
+ const versionMatch = trimmed.match(/^-?\s*version:\s*["']?([^"'\s#]+)["']?\s*(#.*)?$/);
690
+ if (versionMatch && versionMatch[1] === String(version)) {
691
+ return true;
692
+ }
693
+ }
694
+
695
+ return false;
696
+ }
697
+
698
+ async function fetchChartVersionFromRegistry(chartName, version) {
699
+ const project = getGitlabProject();
700
+ if (!project) {
701
+ return { ok: false, reason: 'Не удалось определить GitLab-проект из remote "origin".' };
702
+ }
703
+
704
+ const accessToken = process.env.GITLAB_PRIVATE_TOKEN;
705
+ if (!accessToken) {
706
+ return {
707
+ ok: false,
708
+ reason: 'Не задана переменная окружения GITLAB_PRIVATE_TOKEN. Она нужна для проверки публикации чарта в Helm-registry.'
709
+ };
710
+ }
711
+
712
+ const indexUrl = `${project.origin}/api/v4/projects/${project.encodedPath}/packages/helm/${HELM_REGISTRY_CHANNEL}/index.yaml`;
713
+ let response;
714
+ try {
715
+ response = await fetch(indexUrl, { headers: { 'PRIVATE-TOKEN': accessToken } });
716
+ } catch (error) {
717
+ return { ok: false, reason: `Не удалось запросить Helm-registry (${indexUrl}): ${error.message}.` };
718
+ }
719
+
720
+ if (!response.ok) {
721
+ return { ok: false, reason: `Helm-registry вернул HTTP ${response.status} (${indexUrl}).` };
722
+ }
723
+
724
+ const indexYaml = await response.text();
725
+ return {
726
+ ok: true,
727
+ found: helmIndexHasChartVersion(indexYaml, chartName, version),
728
+ indexUrl
729
+ };
730
+ }
731
+
732
+ async function waitForChartInRegistry(chartName, version, options = {}) {
733
+ const timeoutMs = options.timeoutMs === undefined ? REGISTRY_WAIT_TIMEOUT_MS : options.timeoutMs;
734
+ const intervalMs = options.intervalMs === undefined ? REGISTRY_WAIT_INTERVAL_MS : options.intervalMs;
735
+ const sleepFn = options.sleep || sleep;
736
+ const deadline = Date.now() + timeoutMs;
737
+
738
+ while (true) {
739
+ const result = await fetchChartVersionFromRegistry(chartName, version);
740
+ if (!result.ok) {
741
+ logError('❌', '%s', result.reason);
742
+ return false;
743
+ }
744
+ if (result.found) {
745
+ logSuccess('📦', 'Версия %s чарта %s опубликована в Helm-registry.', version, chartName);
746
+ return true;
747
+ }
748
+ if (Date.now() >= deadline) {
749
+ logError('❌', 'Версия %s чарта %s не появилась в Helm-registry за отведенное время.', version, chartName);
750
+ return false;
751
+ }
752
+ logSuccess('⏳', 'Версия %s чарта %s еще не опубликована, продолжаю ожидание...', version, chartName);
753
+ await sleepFn(intervalMs);
754
+ }
755
+ }
756
+
757
+ function getInstanceName(filePath) {
758
+ const normalized = String(filePath || '').replace(/\\/g, '/');
759
+ const match = normalized.match(/(?:^|\/)instances\/([^/]+)\/helmrelease\.yaml$/i);
760
+ return match ? match[1] : null;
761
+ }
762
+
763
+ function parseInstancesOption(raw) {
764
+ return normalizeList(String(raw || '').split(','));
765
+ }
766
+
586
767
  function updateHelmReleaseVersion(filePath, nextVersion) {
587
768
  const content = fs.readFileSync(filePath, 'utf8');
588
769
  const lines = content.split('\n');
@@ -672,7 +853,9 @@ function splitGitNameOnly(output) {
672
853
  .filter(Boolean);
673
854
  }
674
855
 
675
- function chartDeploy() {
856
+ function chartDeploy(options = {}) {
857
+ const instancesOption = options.instances;
858
+
676
859
  return runCommand({
677
860
  name: 'chart deploy',
678
861
  checks: [
@@ -683,7 +866,41 @@ function chartDeploy() {
683
866
  { name: 'remote-origin', run: requireRemoteOrigin },
684
867
  { name: 'remote-reachable', run: requireRemoteReachable },
685
868
  { name: 'single-chart', run: requireSingleChart },
686
- { name: 'helmrelease-files', run: requireHelmReleaseFiles }
869
+ { name: 'helmrelease-files', run: requireHelmReleaseFiles },
870
+ {
871
+ name: 'filter-instances',
872
+ run: (ctx) => {
873
+ const requested = parseInstancesOption(instancesOption);
874
+ if (requested.length === 0) {
875
+ return { ok: true };
876
+ }
877
+
878
+ const available = new Map();
879
+ for (const filePath of ctx.helmReleaseFiles || []) {
880
+ const instanceName = getInstanceName(filePath);
881
+ if (instanceName) {
882
+ available.set(instanceName, filePath);
883
+ }
884
+ }
885
+
886
+ if (available.size === 0) {
887
+ return {
888
+ ok: false,
889
+ reason: 'В репозитории нет файлов instances/<name>/helmrelease.yaml, флаг --instances неприменим.'
890
+ };
891
+ }
892
+
893
+ const unknown = requested.filter((name) => !available.has(name));
894
+ if (unknown.length > 0) {
895
+ return {
896
+ ok: false,
897
+ reason: `Неизвестные инстансы: ${unknown.join(', ')}. Доступные: ${Array.from(available.keys()).join(', ')}.`
898
+ };
899
+ }
900
+
901
+ return { ok: true, data: { helmReleaseFiles: requested.map((name) => available.get(name)) } };
902
+ }
903
+ }
687
904
  ],
688
905
  steps: [
689
906
  {
@@ -699,12 +916,34 @@ function chartDeploy() {
699
916
  return true;
700
917
  }
701
918
  },
919
+ {
920
+ name: 'verify-chart-in-registry',
921
+ run: async (ctx) => {
922
+ const result = await fetchChartVersionFromRegistry(ctx.chartName, ctx.latestChartVersion);
923
+ if (!result.ok) {
924
+ logError('❌', '%s', result.reason);
925
+ return false;
926
+ }
927
+ if (!result.found) {
928
+ logError(
929
+ '❌',
930
+ 'Версия %s чарта %s отсутствует в Helm-registry. Дождитесь пайплайна "upload charts" и повторите.',
931
+ ctx.latestChartVersion,
932
+ ctx.chartName
933
+ );
934
+ return false;
935
+ }
936
+ logSuccess('📦', 'Версия %s чарта %s найдена в Helm-registry.', ctx.latestChartVersion, ctx.chartName);
937
+ return true;
938
+ }
939
+ },
702
940
  {
703
941
  name: 'update-helmrelease-files',
704
942
  run: (ctx) => {
705
943
  const changes = (ctx.helmReleaseFiles || []).map((filePath) => updateHelmReleaseVersion(filePath, ctx.latestChartVersion));
706
944
  ctx.deployChanges = changes;
707
945
  ctx.updatedFiles = changes.filter((item) => item.changed).map((item) => item.filePath);
946
+ ctx.updatedInstances = ctx.updatedFiles.map((filePath) => getInstanceName(filePath) || filePath);
708
947
 
709
948
  printDeployChanges(changes);
710
949
  if (ctx.updatedFiles.length === 0) {
@@ -713,6 +952,7 @@ function chartDeploy() {
713
952
  return true;
714
953
  }
715
954
 
955
+ logSuccess('📋', 'Будут обновлены инстансы: %s (версия чарта %s).', ctx.updatedInstances.join(', '), ctx.latestChartVersion);
716
956
  return true;
717
957
  }
718
958
  },
@@ -786,7 +1026,12 @@ function chartDeploy() {
786
1026
  return false;
787
1027
  }
788
1028
 
789
- if (!execCommand('git commit -m "🚀 Деплой сервиса."')) {
1029
+ const instanceNames = (ctx.updatedInstances && ctx.updatedInstances.length > 0)
1030
+ ? ctx.updatedInstances
1031
+ : normalizedFiles.map((filePath) => getInstanceName(filePath) || filePath);
1032
+ const versionPart = ctx.latestChartVersion ? ` ${ctx.latestChartVersion}` : '';
1033
+ const commitMessage = `🚀 Деплой сервиса${versionPart} (${instanceNames.join(', ')}).`;
1034
+ if (!execCommand(`git commit -m "${commitMessage}"`)) {
790
1035
  logError('❌', 'Не удалось создать коммит деплоя.');
791
1036
  return false;
792
1037
  }
@@ -817,5 +1062,10 @@ module.exports = {
817
1062
  isSemver,
818
1063
  compareSemver,
819
1064
  getLatestRemoteChartVersion,
1065
+ helmIndexHasChartVersion,
1066
+ fetchChartVersionFromRegistry,
1067
+ waitForChartInRegistry,
1068
+ getInstanceName,
1069
+ parseInstancesOption,
820
1070
  updateHelmReleaseVersion
821
1071
  };
package/src/git.js CHANGED
@@ -6,7 +6,7 @@ const {
6
6
  requireCleanWorkingTree,
7
7
  requireOnMainBranch,
8
8
  requireCurrentBranchUpToDateWithRemote,
9
- requirePackageVersion,
9
+ requireChangelogReleaseVersion,
10
10
  requireTagMissing
11
11
  } = require('./preflight');
12
12
 
@@ -86,7 +86,7 @@ function gitCreateTagAndPush() {
86
86
  { name: 'clean-working-tree', run: requireCleanWorkingTree },
87
87
  { name: 'on-main-branch', run: requireOnMainBranch },
88
88
  { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
89
- { name: 'package-version', run: requirePackageVersion },
89
+ { name: 'changelog-release-version', run: requireChangelogReleaseVersion },
90
90
  {
91
91
  name: 'tag-missing',
92
92
  run: (ctx) => requireTagMissing(`v${ctx.version}`)