spectrum-cli 2.1.1 → 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/CHANGELOG.md +14 -24
- package/README.md +97 -60
- package/index.js +16 -33
- package/package.json +1 -1
- package/src/changelog-config.js +38 -0
- package/src/changelog.js +222 -251
- package/src/chart.js +256 -6
- package/src/git.js +2 -2
- package/src/preflight.js +255 -25
- package/src/release.js +95 -108
- package/src/token.js +27 -19
- package/src/utils.js +0 -10
- package/src/version.js +31 -40
- package/src/UNRELEASED.md +0 -33
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
|
-
|
|
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
|
-
|
|
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: '
|
|
89
|
+
{ name: 'changelog-release-version', run: requireChangelogReleaseVersion },
|
|
90
90
|
{
|
|
91
91
|
name: 'tag-missing',
|
|
92
92
|
run: (ctx) => requireTagMissing(`v${ctx.version}`)
|