@axinom/mosaic-cli 0.58.0-rc.9 → 0.58.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/dist/esm/commands/hosting/service/runtime-logs/service-logs-command.d.ts +3 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs-command.js +70 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs-command.js.map +1 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs-options.d.ts +15 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs-options.js +2 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs-options.js.map +1 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs.d.ts +58 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs.js +252 -0
- package/dist/esm/commands/hosting/service/runtime-logs/service-logs.js.map +1 -0
- package/dist/esm/commands/hosting/service/service-commands.js +2 -1
- package/dist/esm/commands/hosting/service/service-commands.js.map +1 -1
- package/package.json +5 -5
- package/src/commands/hosting/service/runtime-logs/service-logs-command.ts +84 -0
- package/src/commands/hosting/service/runtime-logs/service-logs-options.ts +15 -0
- package/src/commands/hosting/service/runtime-logs/service-logs.spec.ts +178 -0
- package/src/commands/hosting/service/runtime-logs/service-logs.ts +356 -0
- package/src/commands/hosting/service/service-commands.ts +6 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getServiceLogs, validateServiceLogsArgs } from './service-logs.js';
|
|
3
|
+
const { red, yellow } = chalk;
|
|
4
|
+
export const serviceLogs = {
|
|
5
|
+
builder: (yargs) => {
|
|
6
|
+
return yargs
|
|
7
|
+
.option('serviceId', {
|
|
8
|
+
describe: 'Service ID to read runtime logs for',
|
|
9
|
+
alias: 'i',
|
|
10
|
+
string: true,
|
|
11
|
+
demandOption: true,
|
|
12
|
+
})
|
|
13
|
+
.option('tail', {
|
|
14
|
+
describe: 'Maximum number of log lines to show per replica',
|
|
15
|
+
alias: 't',
|
|
16
|
+
number: true,
|
|
17
|
+
default: 100,
|
|
18
|
+
})
|
|
19
|
+
.option('since', {
|
|
20
|
+
describe: 'Only show log lines newer than the given age, for example 30s, 5m, 2h or 1d. A plain number is read as seconds.',
|
|
21
|
+
string: true,
|
|
22
|
+
})
|
|
23
|
+
.option('timestamps', {
|
|
24
|
+
describe: 'Show the timestamp Kubernetes recorded for each line. Lines are ordered by it either way.',
|
|
25
|
+
boolean: true,
|
|
26
|
+
})
|
|
27
|
+
.option('json', {
|
|
28
|
+
describe: 'Emit one JSON object per line ({replicaName, timestamp, message}), in the same order as the normal output. Makes --prefix and --timestamps redundant.',
|
|
29
|
+
boolean: true,
|
|
30
|
+
})
|
|
31
|
+
.option('prefix', {
|
|
32
|
+
describe: 'Prefix each line with the replica it came from. On by default when the service has more than one replica. Use --no-prefix when piping the raw lines to tools such as grep or awk.',
|
|
33
|
+
boolean: true,
|
|
34
|
+
})
|
|
35
|
+
.option('mosaicHostingClientId', {
|
|
36
|
+
describe: 'Service Account Client ID to authenticate',
|
|
37
|
+
alias: 'c',
|
|
38
|
+
string: true,
|
|
39
|
+
})
|
|
40
|
+
.option('mosaicHostingClientSecret', {
|
|
41
|
+
describe: 'Service Account Client Secret to authenticate',
|
|
42
|
+
alias: 's',
|
|
43
|
+
string: true,
|
|
44
|
+
})
|
|
45
|
+
.option('idServiceAuthBaseURL', {
|
|
46
|
+
describe: 'ID Service Authentication Endpoint Base URL',
|
|
47
|
+
alias: 'a',
|
|
48
|
+
string: true,
|
|
49
|
+
})
|
|
50
|
+
.option('hostingServiceBaseURL', {
|
|
51
|
+
describe: 'Hosting Service Base URL',
|
|
52
|
+
alias: 'h',
|
|
53
|
+
string: true,
|
|
54
|
+
})
|
|
55
|
+
.example('$0 hosting service logs -i channel-service', 'Show the last 100 log lines of each replica')
|
|
56
|
+
.example('$0 hosting service logs -i channel-service --tail 500 > service.log', 'Write log lines to a file (status messages stay on stderr)');
|
|
57
|
+
},
|
|
58
|
+
handler: async (args) => {
|
|
59
|
+
const [validatedArgs, errorMessages] = validateServiceLogsArgs(args);
|
|
60
|
+
if (errorMessages.length > 0) {
|
|
61
|
+
process.stderr.write(red('Some required arguments are missing.\n'));
|
|
62
|
+
process.stderr.write(yellow(errorMessages.join('\n') + '\n'));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
await getServiceLogs(validatedArgs);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=service-logs-command.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-logs-command.js","sourceRoot":"","sources":["../../../../../../src/commands/hosting/service/runtime-logs/service-logs-command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,OAAO,EAAE,cAAc,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAE5E,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;AAE9B,MAAM,CAAC,MAAM,WAAW,GAA+C;IACrE,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;QACjB,OAAO,KAAK;aACT,MAAM,CAAC,WAAW,EAAE;YACnB,QAAQ,EAAE,qCAAqC;YAC/C,KAAK,EAAE,GAAG;YACV,MAAM,EAAE,IAAI;YACZ,YAAY,EAAE,IAAI;SACnB,CAAC;aACD,MAAM,CAAC,MAAM,EAAE;YACd,QAAQ,EAAE,iDAAiD;YAC3D,KAAK,EAAE,GAAG;YACV,MAAM,EAAE,IAAI;YACZ,OAAO,EAAE,GAAG;SACb,CAAC;aACD,MAAM,CAAC,OAAO,EAAE;YACf,QAAQ,EACN,iHAAiH;YACnH,MAAM,EAAE,IAAI;SACb,CAAC;aACD,MAAM,CAAC,YAAY,EAAE;YACpB,QAAQ,EACN,2FAA2F;YAC7F,OAAO,EAAE,IAAI;SACd,CAAC;aACD,MAAM,CAAC,MAAM,EAAE;YACd,QAAQ,EACN,uJAAuJ;YACzJ,OAAO,EAAE,IAAI;SACd,CAAC;aACD,MAAM,CAAC,QAAQ,EAAE;YAChB,QAAQ,EACN,mLAAmL;YACrL,OAAO,EAAE,IAAI;SACd,CAAC;aACD,MAAM,CAAC,uBAAuB,EAAE;YAC/B,QAAQ,EAAE,2CAA2C;YACrD,KAAK,EAAE,GAAG;YACV,MAAM,EAAE,IAAI;SACb,CAAC;aACD,MAAM,CAAC,2BAA2B,EAAE;YACnC,QAAQ,EAAE,+CAA+C;YACzD,KAAK,EAAE,GAAG;YACV,MAAM,EAAE,IAAI;SACb,CAAC;aACD,MAAM,CAAC,sBAAsB,EAAE;YAC9B,QAAQ,EAAE,6CAA6C;YACvD,KAAK,EAAE,GAAG;YACV,MAAM,EAAE,IAAI;SACb,CAAC;aACD,MAAM,CAAC,uBAAuB,EAAE;YAC/B,QAAQ,EAAE,0BAA0B;YACpC,KAAK,EAAE,GAAG;YACV,MAAM,EAAE,IAAI;SACb,CAAC;aACD,OAAO,CACN,4CAA4C,EAC5C,6CAA6C,CAC9C;aACA,OAAO,CACN,qEAAqE,EACrE,4DAA4D,CAC7D,CAAC;IACN,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QACtB,MAAM,CAAC,aAAa,EAAE,aAAa,CAAC,GAAG,uBAAuB,CAAC,IAAI,CAAC,CAAC;QACrE,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC7B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,wCAAwC,CAAC,CAAC,CAAC;YACpE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;YAC9D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;aAAM,CAAC;YACN,MAAM,cAAc,CAClB,aAA2D,CAC5D,CAAC;QACJ,CAAC;IACH,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for reading the runtime logs of a deployed service.
|
|
3
|
+
*/
|
|
4
|
+
export interface ServiceLogsOptions {
|
|
5
|
+
serviceId: string;
|
|
6
|
+
tail?: number;
|
|
7
|
+
since?: string;
|
|
8
|
+
json?: boolean;
|
|
9
|
+
prefix?: boolean;
|
|
10
|
+
timestamps?: boolean;
|
|
11
|
+
mosaicHostingClientId?: string;
|
|
12
|
+
mosaicHostingClientSecret?: string;
|
|
13
|
+
idServiceAuthBaseURL?: string;
|
|
14
|
+
hostingServiceBaseURL?: string;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-logs-options.js","sourceRoot":"","sources":["../../../../../../src/commands/hosting/service/runtime-logs/service-logs-options.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ServiceLogsOptions } from './service-logs-options.js';
|
|
2
|
+
/**
|
|
3
|
+
* The GraphQL shape returned by Hosting, which is deliberately declared here
|
|
4
|
+
* rather than imported: this package is published, and the SDU packages that
|
|
5
|
+
* own the equivalent REST DTO are private.
|
|
6
|
+
*/
|
|
7
|
+
export interface ServiceReplicaLog {
|
|
8
|
+
replicaName: string;
|
|
9
|
+
lines: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Replica names are full K8s pod names, e.g.
|
|
13
|
+
* `d-8ip51mklekzyxmtk-5ihjg3ycvjkpnmb2-ax-media-service-bb9c8cfmfq`. Everything
|
|
14
|
+
* up to and including the service ID is identical across replicas of the
|
|
15
|
+
* service that was asked for, so only the trailing segment carries
|
|
16
|
+
* information -- and 60+ characters in front of every line is unreadable.
|
|
17
|
+
* `--json` keeps the full name.
|
|
18
|
+
*/
|
|
19
|
+
export declare const toShortReplicaName: (replicaName: string, serviceId: string) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Reads an age such as `30s`, `5m`, `2h` or `1d` into seconds, matching how
|
|
22
|
+
* `kubectl logs --since` and `docker logs --since` are written. A bare number
|
|
23
|
+
* is read as seconds. Returns undefined when the value cannot be read.
|
|
24
|
+
*/
|
|
25
|
+
export declare const parseSinceToSeconds: (since: string | undefined) => number | undefined;
|
|
26
|
+
interface OrderedLine {
|
|
27
|
+
/** RFC3339 timestamp Kubernetes recorded for the line, used for ordering. */
|
|
28
|
+
sortKey: string;
|
|
29
|
+
replicaName: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Pads the fractional seconds to a fixed width so timestamps can be ordered by
|
|
35
|
+
* plain string comparison.
|
|
36
|
+
*
|
|
37
|
+
* RFC3339Nano permits a variable-length fraction, and comparing those directly
|
|
38
|
+
* misorders them: `.12Z` is earlier than `.1201Z`, but sorts after it because
|
|
39
|
+
* `Z` compares above `0`. Padding avoids that while keeping the precision a
|
|
40
|
+
* `Date` would round away. A value that does not parse is returned unchanged
|
|
41
|
+
* and simply orders lexicographically.
|
|
42
|
+
*/
|
|
43
|
+
export declare const toTimestampSortKey: (timestamp: string) => string;
|
|
44
|
+
/**
|
|
45
|
+
* Merges every replica's lines into one chronological stream.
|
|
46
|
+
*
|
|
47
|
+
* The keys are RFC3339 UTC strings of identical shape, so comparing them as
|
|
48
|
+
* strings orders them correctly and preserves the sub-millisecond precision
|
|
49
|
+
* that parsing into a `Date` would discard. Sorting is stable, so lines sharing
|
|
50
|
+
* a timestamp keep their original per-replica order.
|
|
51
|
+
*/
|
|
52
|
+
export declare const mergeReplicaLines: (replicas: ServiceReplicaLog[]) => OrderedLine[];
|
|
53
|
+
export declare const getServiceLogs: (args: Required<Pick<ServiceLogsOptions, "serviceId">> & ServiceLogsOptions) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Validate arguments for reading service logs.
|
|
56
|
+
*/
|
|
57
|
+
export declare const validateServiceLogsArgs: (args: ServiceLogsOptions) => [ServiceLogsOptions, string[]];
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { getServiceAccountToken } from '@axinom/mosaic-id-link-be';
|
|
2
|
+
import { isNullOrWhitespace } from '@axinom/mosaic-service-common';
|
|
3
|
+
import axios, { AxiosError } from 'axios';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { exitCode } from '../../../../common/index.js';
|
|
6
|
+
const { red, dim } = chalk;
|
|
7
|
+
/**
|
|
8
|
+
* Log lines go to stdout and nothing else does; status messages and errors go
|
|
9
|
+
* to stderr. This keeps `mosaic hosting service logs -i svc > out.log` a file
|
|
10
|
+
* of pure log output, and keeps `| grep` results free of decoration.
|
|
11
|
+
*/
|
|
12
|
+
const writeLogLine = (line) => {
|
|
13
|
+
process.stdout.write(line + '\n');
|
|
14
|
+
};
|
|
15
|
+
const writeStatus = (message) => {
|
|
16
|
+
process.stderr.write(message + '\n');
|
|
17
|
+
};
|
|
18
|
+
const getAxiosInstance = (hostingServiceBaseUrl, serviceAccountToken) => {
|
|
19
|
+
return axios.create({
|
|
20
|
+
baseURL: new URL(hostingServiceBaseUrl).toString(),
|
|
21
|
+
headers: {
|
|
22
|
+
Authorization: `Bearer ${serviceAccountToken}`,
|
|
23
|
+
'content-type': 'application/json',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Replica names are full K8s pod names, e.g.
|
|
29
|
+
* `d-8ip51mklekzyxmtk-5ihjg3ycvjkpnmb2-ax-media-service-bb9c8cfmfq`. Everything
|
|
30
|
+
* up to and including the service ID is identical across replicas of the
|
|
31
|
+
* service that was asked for, so only the trailing segment carries
|
|
32
|
+
* information -- and 60+ characters in front of every line is unreadable.
|
|
33
|
+
* `--json` keeps the full name.
|
|
34
|
+
*/
|
|
35
|
+
export const toShortReplicaName = (replicaName, serviceId) => {
|
|
36
|
+
const marker = `-${serviceId}-`;
|
|
37
|
+
const index = replicaName.lastIndexOf(marker);
|
|
38
|
+
if (index === -1) {
|
|
39
|
+
return replicaName;
|
|
40
|
+
}
|
|
41
|
+
const suffix = replicaName.slice(index + marker.length);
|
|
42
|
+
return suffix.length > 0 ? suffix : replicaName;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Drops the empty element left by the log's trailing newline, while preserving
|
|
46
|
+
* blank lines inside the log: a service not using
|
|
47
|
+
* `@axinom/mosaic-service-common` may emit plain multi-line text where blank
|
|
48
|
+
* lines are meaningful.
|
|
49
|
+
*/
|
|
50
|
+
const stripTrailingBlank = (lines) => {
|
|
51
|
+
const result = [...lines];
|
|
52
|
+
while (result.length > 0 && result[result.length - 1] === '') {
|
|
53
|
+
result.pop();
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
const SINCE_UNITS_IN_SECONDS = {
|
|
58
|
+
s: 1,
|
|
59
|
+
m: 60,
|
|
60
|
+
h: 60 * 60,
|
|
61
|
+
d: 24 * 60 * 60,
|
|
62
|
+
};
|
|
63
|
+
const SINCE_PATTERN = /^(\d+)(s|m|h|d)?$/;
|
|
64
|
+
/**
|
|
65
|
+
* Reads an age such as `30s`, `5m`, `2h` or `1d` into seconds, matching how
|
|
66
|
+
* `kubectl logs --since` and `docker logs --since` are written. A bare number
|
|
67
|
+
* is read as seconds. Returns undefined when the value cannot be read.
|
|
68
|
+
*/
|
|
69
|
+
export const parseSinceToSeconds = (since) => {
|
|
70
|
+
if (since === undefined) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const match = SINCE_PATTERN.exec(since.trim());
|
|
74
|
+
if (!match) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const amount = Number(match[1]);
|
|
78
|
+
if (amount <= 0) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return amount * SINCE_UNITS_IN_SECONDS[match[2] ?? 's'];
|
|
82
|
+
};
|
|
83
|
+
const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\S+Z) (.*)$/s;
|
|
84
|
+
const NANO_DIGITS = 9;
|
|
85
|
+
const RFC3339_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/;
|
|
86
|
+
/**
|
|
87
|
+
* Pads the fractional seconds to a fixed width so timestamps can be ordered by
|
|
88
|
+
* plain string comparison.
|
|
89
|
+
*
|
|
90
|
+
* RFC3339Nano permits a variable-length fraction, and comparing those directly
|
|
91
|
+
* misorders them: `.12Z` is earlier than `.1201Z`, but sorts after it because
|
|
92
|
+
* `Z` compares above `0`. Padding avoids that while keeping the precision a
|
|
93
|
+
* `Date` would round away. A value that does not parse is returned unchanged
|
|
94
|
+
* and simply orders lexicographically.
|
|
95
|
+
*/
|
|
96
|
+
export const toTimestampSortKey = (timestamp) => {
|
|
97
|
+
const match = RFC3339_PATTERN.exec(timestamp);
|
|
98
|
+
if (!match) {
|
|
99
|
+
return timestamp;
|
|
100
|
+
}
|
|
101
|
+
return `${match[1]}.${(match[2] ?? '').padEnd(NANO_DIGITS, '0')}Z`;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Splits the timestamp Kubernetes prefixed onto each line from the line itself.
|
|
105
|
+
*
|
|
106
|
+
* A line that does not carry one (which should not happen, but a malformed or
|
|
107
|
+
* truncated log could produce it) inherits the previous line's key so it stays
|
|
108
|
+
* next to the line it belongs with, instead of sorting to the top.
|
|
109
|
+
*/
|
|
110
|
+
const toOrderedLines = (replicaName, lines, previousKey = '') => {
|
|
111
|
+
let lastKey = previousKey;
|
|
112
|
+
return lines.map((line) => {
|
|
113
|
+
const match = TIMESTAMP_PATTERN.exec(line);
|
|
114
|
+
if (match) {
|
|
115
|
+
lastKey = toTimestampSortKey(match[1]);
|
|
116
|
+
return {
|
|
117
|
+
sortKey: lastKey,
|
|
118
|
+
replicaName,
|
|
119
|
+
timestamp: match[1],
|
|
120
|
+
message: match[2],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { sortKey: lastKey, replicaName, timestamp: '', message: line };
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Merges every replica's lines into one chronological stream.
|
|
128
|
+
*
|
|
129
|
+
* The keys are RFC3339 UTC strings of identical shape, so comparing them as
|
|
130
|
+
* strings orders them correctly and preserves the sub-millisecond precision
|
|
131
|
+
* that parsing into a `Date` would discard. Sorting is stable, so lines sharing
|
|
132
|
+
* a timestamp keep their original per-replica order.
|
|
133
|
+
*/
|
|
134
|
+
export const mergeReplicaLines = (replicas) => replicas
|
|
135
|
+
.flatMap((replica) => toOrderedLines(replica.replicaName, stripTrailingBlank(replica.lines)))
|
|
136
|
+
.sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0);
|
|
137
|
+
export const getServiceLogs = async (args) => {
|
|
138
|
+
const serviceAccountToken = await getServiceAccountToken(args.idServiceAuthBaseURL, args.mosaicHostingClientId, args.mosaicHostingClientSecret);
|
|
139
|
+
const axiosInstance = getAxiosInstance(args.hostingServiceBaseURL, serviceAccountToken.accessToken);
|
|
140
|
+
const query = `query GetServiceRuntimeLogs($serviceId: String!, $tailLines: Int, $sinceSeconds: Int) {
|
|
141
|
+
serviceRuntimeLogs(input: { serviceId: $serviceId, tailLines: $tailLines, sinceSeconds: $sinceSeconds }) {
|
|
142
|
+
replicas {
|
|
143
|
+
replicaName
|
|
144
|
+
lines
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}`;
|
|
148
|
+
const payload = JSON.stringify({
|
|
149
|
+
query,
|
|
150
|
+
variables: {
|
|
151
|
+
serviceId: args.serviceId,
|
|
152
|
+
tailLines: args.tail,
|
|
153
|
+
sinceSeconds: parseSinceToSeconds(args.since),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
const response = await axiosInstance.post('graphql', payload);
|
|
158
|
+
if (response.data.errors !== undefined && response.data.errors.length > 0) {
|
|
159
|
+
writeStatus(red(`Failed to retrieve logs for service [${args.serviceId}].`));
|
|
160
|
+
writeStatus(red(response.data.errors[0].message));
|
|
161
|
+
process.exit(exitCode);
|
|
162
|
+
}
|
|
163
|
+
const replicas = response.data.data?.serviceRuntimeLogs?.replicas ?? [];
|
|
164
|
+
const merged = mergeReplicaLines(replicas);
|
|
165
|
+
// Newline-delimited JSON: the same ordering as the human output, one object
|
|
166
|
+
// per line so it streams into `jq` and friends. The replica and timestamp
|
|
167
|
+
// are always present as fields, which is why --prefix and --timestamps do
|
|
168
|
+
// not apply here.
|
|
169
|
+
if (args.json) {
|
|
170
|
+
for (const line of merged) {
|
|
171
|
+
writeLogLine(JSON.stringify({
|
|
172
|
+
replicaName: line.replicaName,
|
|
173
|
+
timestamp: line.timestamp,
|
|
174
|
+
message: line.message,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (replicas.length === 0) {
|
|
180
|
+
writeStatus(dim(`No running replicas found for service [${args.serviceId}].`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// With one replica the prefix is noise on every line; with several it is
|
|
184
|
+
// the only way to tell which replica said what.
|
|
185
|
+
const usePrefix = args.prefix ?? replicas.length > 1;
|
|
186
|
+
writeStatus(dim(`# ${args.serviceId}: ${replicas.length} replica(s)${args.tail ? `, last ${args.tail} line(s) each` : ''}`));
|
|
187
|
+
for (const line of merged) {
|
|
188
|
+
// The service's own text is emitted verbatim: it may be plain text
|
|
189
|
+
// rather than JSON, and any reformatting would corrupt it.
|
|
190
|
+
const prefix = usePrefix
|
|
191
|
+
? `[${toShortReplicaName(line.replicaName, args.serviceId)}] `
|
|
192
|
+
: '';
|
|
193
|
+
const stamp = args.timestamps && line.timestamp ? `${line.timestamp} ` : '';
|
|
194
|
+
writeLogLine(`${prefix}${stamp}${line.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const axiosError = error;
|
|
199
|
+
writeStatus(red(`Error while retrieving logs for service [${args.serviceId}].`));
|
|
200
|
+
writeStatus(red(`Error: ${axiosError.message}`));
|
|
201
|
+
if (axiosError.response) {
|
|
202
|
+
writeStatus(red(`Status: ${axiosError.response.status}`));
|
|
203
|
+
writeStatus(red(`Response: ${JSON.stringify(axiosError.response.data, null, 2)}`));
|
|
204
|
+
}
|
|
205
|
+
process.exit(exitCode);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Validate arguments for reading service logs.
|
|
210
|
+
*/
|
|
211
|
+
export const validateServiceLogsArgs = (args) => {
|
|
212
|
+
const errorMessages = [];
|
|
213
|
+
const idServiceAuthBaseURL = args.idServiceAuthBaseURL ?? process.env.ID_SERVICE_AUTH_BASE_URL ?? '';
|
|
214
|
+
const mosaicHostingClientId = args.mosaicHostingClientId ?? process.env.MOSAIC_HOSTING_CLIENT_ID ?? '';
|
|
215
|
+
const mosaicHostingClientSecret = args.mosaicHostingClientSecret ??
|
|
216
|
+
process.env.MOSAIC_HOSTING_CLIENT_SECRET ??
|
|
217
|
+
'';
|
|
218
|
+
const hostingServiceBaseURL = args.hostingServiceBaseURL ?? process.env.HOSTING_SERVICE_BASE_URL ?? '';
|
|
219
|
+
if (isNullOrWhitespace(idServiceAuthBaseURL)) {
|
|
220
|
+
errorMessages.push('[idServiceAuthBaseURL] is required.');
|
|
221
|
+
}
|
|
222
|
+
if (isNullOrWhitespace(mosaicHostingClientId)) {
|
|
223
|
+
errorMessages.push('[mosaicHostingClientId] is required.');
|
|
224
|
+
}
|
|
225
|
+
if (isNullOrWhitespace(mosaicHostingClientSecret)) {
|
|
226
|
+
errorMessages.push('[mosaicHostingClientSecret] is required.');
|
|
227
|
+
}
|
|
228
|
+
if (isNullOrWhitespace(hostingServiceBaseURL)) {
|
|
229
|
+
errorMessages.push('[hostingServiceBaseURL] is required.');
|
|
230
|
+
}
|
|
231
|
+
if (isNullOrWhitespace(args.serviceId)) {
|
|
232
|
+
errorMessages.push('[serviceId] is required.');
|
|
233
|
+
}
|
|
234
|
+
if (args.tail !== undefined && (args.tail < 1 || args.tail > 1000)) {
|
|
235
|
+
errorMessages.push('[tail] must be between 1 and 1000.');
|
|
236
|
+
}
|
|
237
|
+
if (args.since !== undefined &&
|
|
238
|
+
parseSinceToSeconds(args.since) === undefined) {
|
|
239
|
+
errorMessages.push('[since] must be a positive age such as 30s, 5m, 2h or 1d.');
|
|
240
|
+
}
|
|
241
|
+
return [
|
|
242
|
+
{
|
|
243
|
+
...args,
|
|
244
|
+
idServiceAuthBaseURL,
|
|
245
|
+
mosaicHostingClientId,
|
|
246
|
+
mosaicHostingClientSecret,
|
|
247
|
+
hostingServiceBaseURL,
|
|
248
|
+
},
|
|
249
|
+
errorMessages,
|
|
250
|
+
];
|
|
251
|
+
};
|
|
252
|
+
//# sourceMappingURL=service-logs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-logs.js","sourceRoot":"","sources":["../../../../../../src/commands/hosting/service/runtime-logs/service-logs.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { serviceDeploy } from './deploy/service-deploy-command.js';
|
|
2
|
+
import { serviceLogs } from './runtime-logs/service-logs-command.js';
|
|
2
3
|
import { serviceUndeploy } from './undeploy/service-undeploy-command.js';
|
|
3
4
|
export const serviceCommands = {
|
|
4
5
|
builder: (yargs) => yargs
|
|
5
6
|
.command('deploy', 'Deploy a service through Hosting Service.', serviceDeploy)
|
|
6
|
-
.demandCommand()
|
|
7
7
|
.command('undeploy', 'Undeploy a service through Hosting Service.', serviceUndeploy)
|
|
8
|
+
.command('logs', 'Read the runtime logs of a deployed service.', serviceLogs)
|
|
8
9
|
.demandCommand(),
|
|
9
10
|
handler: () => {
|
|
10
11
|
console.log('Please pick an action related to hosting service command.');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-commands.js","sourceRoot":"","sources":["../../../../../src/commands/hosting/service/service-commands.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,MAAM,wCAAwC,CAAC;AAEzE,MAAM,CAAC,MAAM,eAAe,GAAoC;IAC9D,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CACjB,KAAK;SACF,OAAO,CACN,QAAQ,EACR,2CAA2C,EAC3C,aAAa,CACd;SACA,
|
|
1
|
+
{"version":3,"file":"service-commands.js","sourceRoot":"","sources":["../../../../../src/commands/hosting/service/service-commands.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,wCAAwC,CAAC;AACrE,OAAO,EAAE,eAAe,EAAE,MAAM,wCAAwC,CAAC;AAEzE,MAAM,CAAC,MAAM,eAAe,GAAoC;IAC9D,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CACjB,KAAK;SACF,OAAO,CACN,QAAQ,EACR,2CAA2C,EAC3C,aAAa,CACd;SACA,OAAO,CACN,UAAU,EACV,6CAA6C,EAC7C,eAAe,CAChB;SACA,OAAO,CACN,MAAM,EACN,8CAA8C,EAC9C,WAAW,CACZ;SACA,aAAa,EAAE;IACpB,OAAO,EAAE,GAAG,EAAE;QACZ,OAAO,CAAC,GAAG,CAAC,2DAA2D,CAAC,CAAC;IAC3E,CAAC;CACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axinom/mosaic-cli",
|
|
3
|
-
"version": "0.58.0
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "The Axinom Mosaic CLI",
|
|
5
5
|
"author": "Axinom",
|
|
6
6
|
"license": "PROPRIETARY",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"@asyncapi/diff": "^0.5.0",
|
|
48
48
|
"@asyncapi/modelina": "^5.10.1",
|
|
49
49
|
"@asyncapi/parser": "^3.6.0",
|
|
50
|
-
"@axinom/mosaic-id-link-be": "^0.41.0
|
|
51
|
-
"@axinom/mosaic-service-common": "^0.71.0
|
|
52
|
-
"@graphql-inspector/core": "
|
|
50
|
+
"@axinom/mosaic-id-link-be": "^0.41.0",
|
|
51
|
+
"@axinom/mosaic-service-common": "^0.71.0",
|
|
52
|
+
"@graphql-inspector/core": "3.1.2",
|
|
53
53
|
"@inquirer/confirm": "^3.1.0",
|
|
54
54
|
"@inquirer/core": "^9.1.0",
|
|
55
55
|
"@inquirer/input": "^2.1.0",
|
|
@@ -94,5 +94,5 @@
|
|
|
94
94
|
"publishConfig": {
|
|
95
95
|
"access": "public"
|
|
96
96
|
},
|
|
97
|
-
"gitHead": "
|
|
97
|
+
"gitHead": "c423aeda79f59de31422cd52d9821d1b8214c3df"
|
|
98
98
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import type { CommandModule } from 'yargs';
|
|
3
|
+
import type { ServiceLogsOptions } from './service-logs-options.js';
|
|
4
|
+
import { getServiceLogs, validateServiceLogsArgs } from './service-logs.js';
|
|
5
|
+
|
|
6
|
+
const { red, yellow } = chalk;
|
|
7
|
+
|
|
8
|
+
export const serviceLogs: CommandModule<unknown, ServiceLogsOptions> = {
|
|
9
|
+
builder: (yargs) => {
|
|
10
|
+
return yargs
|
|
11
|
+
.option('serviceId', {
|
|
12
|
+
describe: 'Service ID to read runtime logs for',
|
|
13
|
+
alias: 'i',
|
|
14
|
+
string: true,
|
|
15
|
+
demandOption: true,
|
|
16
|
+
})
|
|
17
|
+
.option('tail', {
|
|
18
|
+
describe: 'Maximum number of log lines to show per replica',
|
|
19
|
+
alias: 't',
|
|
20
|
+
number: true,
|
|
21
|
+
default: 100,
|
|
22
|
+
})
|
|
23
|
+
.option('since', {
|
|
24
|
+
describe:
|
|
25
|
+
'Only show log lines newer than the given age, for example 30s, 5m, 2h or 1d. A plain number is read as seconds.',
|
|
26
|
+
string: true,
|
|
27
|
+
})
|
|
28
|
+
.option('timestamps', {
|
|
29
|
+
describe:
|
|
30
|
+
'Show the timestamp Kubernetes recorded for each line. Lines are ordered by it either way.',
|
|
31
|
+
boolean: true,
|
|
32
|
+
})
|
|
33
|
+
.option('json', {
|
|
34
|
+
describe:
|
|
35
|
+
'Emit one JSON object per line ({replicaName, timestamp, message}), in the same order as the normal output. Makes --prefix and --timestamps redundant.',
|
|
36
|
+
boolean: true,
|
|
37
|
+
})
|
|
38
|
+
.option('prefix', {
|
|
39
|
+
describe:
|
|
40
|
+
'Prefix each line with the replica it came from. On by default when the service has more than one replica. Use --no-prefix when piping the raw lines to tools such as grep or awk.',
|
|
41
|
+
boolean: true,
|
|
42
|
+
})
|
|
43
|
+
.option('mosaicHostingClientId', {
|
|
44
|
+
describe: 'Service Account Client ID to authenticate',
|
|
45
|
+
alias: 'c',
|
|
46
|
+
string: true,
|
|
47
|
+
})
|
|
48
|
+
.option('mosaicHostingClientSecret', {
|
|
49
|
+
describe: 'Service Account Client Secret to authenticate',
|
|
50
|
+
alias: 's',
|
|
51
|
+
string: true,
|
|
52
|
+
})
|
|
53
|
+
.option('idServiceAuthBaseURL', {
|
|
54
|
+
describe: 'ID Service Authentication Endpoint Base URL',
|
|
55
|
+
alias: 'a',
|
|
56
|
+
string: true,
|
|
57
|
+
})
|
|
58
|
+
.option('hostingServiceBaseURL', {
|
|
59
|
+
describe: 'Hosting Service Base URL',
|
|
60
|
+
alias: 'h',
|
|
61
|
+
string: true,
|
|
62
|
+
})
|
|
63
|
+
.example(
|
|
64
|
+
'$0 hosting service logs -i channel-service',
|
|
65
|
+
'Show the last 100 log lines of each replica',
|
|
66
|
+
)
|
|
67
|
+
.example(
|
|
68
|
+
'$0 hosting service logs -i channel-service --tail 500 > service.log',
|
|
69
|
+
'Write log lines to a file (status messages stay on stderr)',
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
handler: async (args) => {
|
|
73
|
+
const [validatedArgs, errorMessages] = validateServiceLogsArgs(args);
|
|
74
|
+
if (errorMessages.length > 0) {
|
|
75
|
+
process.stderr.write(red('Some required arguments are missing.\n'));
|
|
76
|
+
process.stderr.write(yellow(errorMessages.join('\n') + '\n'));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
} else {
|
|
79
|
+
await getServiceLogs(
|
|
80
|
+
validatedArgs as ServiceLogsOptions & { serviceId: string },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for reading the runtime logs of a deployed service.
|
|
3
|
+
*/
|
|
4
|
+
export interface ServiceLogsOptions {
|
|
5
|
+
serviceId: string;
|
|
6
|
+
tail?: number;
|
|
7
|
+
since?: string;
|
|
8
|
+
json?: boolean;
|
|
9
|
+
prefix?: boolean;
|
|
10
|
+
timestamps?: boolean;
|
|
11
|
+
mosaicHostingClientId?: string;
|
|
12
|
+
mosaicHostingClientSecret?: string;
|
|
13
|
+
idServiceAuthBaseURL?: string;
|
|
14
|
+
hostingServiceBaseURL?: string;
|
|
15
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
mergeReplicaLines,
|
|
4
|
+
parseSinceToSeconds,
|
|
5
|
+
toShortReplicaName,
|
|
6
|
+
toTimestampSortKey,
|
|
7
|
+
type ServiceReplicaLog,
|
|
8
|
+
} from './service-logs.js';
|
|
9
|
+
|
|
10
|
+
const SERVICE_ID = 'channel-service';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds a line in the shape SDU returns: the RFC3339 instant Kubernetes
|
|
14
|
+
* recorded, a space, then the container's own output.
|
|
15
|
+
*/
|
|
16
|
+
const k8sLine = (timestamp: string, text: string): string =>
|
|
17
|
+
`${timestamp} ${text}`;
|
|
18
|
+
|
|
19
|
+
const replica = (suffix: string, lines: string[]): ServiceReplicaLog => ({
|
|
20
|
+
replicaName: `d-8ip51mklekzyxmtk-5ihjg3ycvjkpnmb2-${SERVICE_ID}-${suffix}`,
|
|
21
|
+
lines: [...lines, ''],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('toShortReplicaName', () => {
|
|
25
|
+
it('keeps only the segment that distinguishes one replica from another', () => {
|
|
26
|
+
expect(
|
|
27
|
+
toShortReplicaName(
|
|
28
|
+
`d-8ip51mklekzyxmtk-5ihjg3ycvjkpnmb2-${SERVICE_ID}-bb9c8cfmfq`,
|
|
29
|
+
SERVICE_ID,
|
|
30
|
+
),
|
|
31
|
+
).toBe('bb9c8cfmfq');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('falls back to the full name when it does not follow the convention', () => {
|
|
35
|
+
expect(toShortReplicaName('some-other-pod', SERVICE_ID)).toBe(
|
|
36
|
+
'some-other-pod',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('mergeReplicaLines', () => {
|
|
42
|
+
it('orders lines from all replicas by the time Kubernetes recorded them', () => {
|
|
43
|
+
const merged = mergeReplicaLines([
|
|
44
|
+
replica('aaaaaaaaaa', [
|
|
45
|
+
k8sLine('2026-07-17T10:12:25.130421355Z', 'first'),
|
|
46
|
+
k8sLine('2026-07-17T10:12:27.000000000Z', 'third'),
|
|
47
|
+
]),
|
|
48
|
+
replica('bbbbbbbbbb', [
|
|
49
|
+
k8sLine('2026-07-17T10:12:26.000000000Z', 'second'),
|
|
50
|
+
k8sLine('2026-07-17T10:12:28.000000000Z', 'fourth'),
|
|
51
|
+
]),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
expect(merged.map((l) => l.message)).toEqual([
|
|
55
|
+
'first',
|
|
56
|
+
'second',
|
|
57
|
+
'third',
|
|
58
|
+
'fourth',
|
|
59
|
+
]);
|
|
60
|
+
expect(
|
|
61
|
+
merged.map((l) => toShortReplicaName(l.replicaName, SERVICE_ID)),
|
|
62
|
+
).toEqual(['aaaaaaaaaa', 'bbbbbbbbbb', 'aaaaaaaaaa', 'bbbbbbbbbb']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('orders timestamps whose fractional part has a different length', () => {
|
|
66
|
+
// RFC3339Nano allows a variable-length fraction. `.12Z` is earlier than
|
|
67
|
+
// `.1201Z`, but compares after it as a plain string.
|
|
68
|
+
const merged = mergeReplicaLines([
|
|
69
|
+
replica('aaaaaaaaaa', [k8sLine('2026-07-17T10:12:25.1201Z', 'later')]),
|
|
70
|
+
replica('bbbbbbbbbb', [k8sLine('2026-07-17T10:12:25.12Z', 'earlier')]),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
expect(merged.map((l) => l.message)).toEqual(['earlier', 'later']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('orders a timestamp with no fractional part before one within the same second', () => {
|
|
77
|
+
const merged = mergeReplicaLines([
|
|
78
|
+
replica('aaaaaaaaaa', [
|
|
79
|
+
k8sLine('2026-07-17T10:12:25.000000001Z', 'later'),
|
|
80
|
+
]),
|
|
81
|
+
replica('bbbbbbbbbb', [k8sLine('2026-07-17T10:12:25Z', 'earlier')]),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
expect(merged.map((l) => l.message)).toEqual(['earlier', 'later']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('orders sub-millisecond timestamps that a Date would collapse', () => {
|
|
88
|
+
const merged = mergeReplicaLines([
|
|
89
|
+
replica('aaaaaaaaaa', [
|
|
90
|
+
k8sLine('2026-07-17T10:12:25.000000900Z', 'later'),
|
|
91
|
+
]),
|
|
92
|
+
replica('bbbbbbbbbb', [
|
|
93
|
+
k8sLine('2026-07-17T10:12:25.000000100Z', 'earlier'),
|
|
94
|
+
]),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
expect(merged.map((l) => l.message)).toEqual(['earlier', 'later']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('separates the timestamp from the service output without altering it', () => {
|
|
101
|
+
const json =
|
|
102
|
+
'{"logtime":"2026-07-17T10:12:25.130Z","loglevel":"INFO","message":"up"}';
|
|
103
|
+
const [line] = mergeReplicaLines([
|
|
104
|
+
replica('aaaaaaaaaa', [k8sLine('2026-07-17T10:12:25.130421355Z', json)]),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
expect(line.timestamp).toBe('2026-07-17T10:12:25.130421355Z');
|
|
108
|
+
expect(line.message).toBe(json);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('keeps a line without a timestamp beside the line it belongs to', () => {
|
|
112
|
+
// A service not using the Mosaic logger can emit output that arrives
|
|
113
|
+
// without a usable stamp; it must not sort to the top of the stream.
|
|
114
|
+
const merged = mergeReplicaLines([
|
|
115
|
+
replica('aaaaaaaaaa', [
|
|
116
|
+
k8sLine('2026-07-17T10:12:25.000000000Z', 'Traceback:'),
|
|
117
|
+
' at line 2 of the stack',
|
|
118
|
+
k8sLine('2026-07-17T10:12:26.000000000Z', 'after'),
|
|
119
|
+
]),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
expect(merged.map((l) => l.message)).toEqual([
|
|
123
|
+
'Traceback:',
|
|
124
|
+
' at line 2 of the stack',
|
|
125
|
+
'after',
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('preserves blank lines inside the log but drops the trailing newline', () => {
|
|
130
|
+
const merged = mergeReplicaLines([
|
|
131
|
+
replica('aaaaaaaaaa', [
|
|
132
|
+
k8sLine('2026-07-17T10:12:25.000000000Z', 'before'),
|
|
133
|
+
k8sLine('2026-07-17T10:12:25.500000000Z', ''),
|
|
134
|
+
k8sLine('2026-07-17T10:12:26.000000000Z', 'after'),
|
|
135
|
+
]),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
expect(merged.map((l) => l.message)).toEqual(['before', '', 'after']);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('parseSinceToSeconds', () => {
|
|
143
|
+
it.each([
|
|
144
|
+
['45s', 45],
|
|
145
|
+
['5m', 300],
|
|
146
|
+
['2h', 7200],
|
|
147
|
+
['1d', 86400],
|
|
148
|
+
['90', 90],
|
|
149
|
+
])('reads %s as %i seconds', (since, expected) => {
|
|
150
|
+
expect(parseSinceToSeconds(since)).toBe(expected);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.each(['0', '0m', '-5m', '5 m', 'five minutes', '5w', '1.5h', ''])(
|
|
154
|
+
'rejects %s rather than silently sending a wrong window',
|
|
155
|
+
(since) => {
|
|
156
|
+
expect(parseSinceToSeconds(since)).toBeUndefined();
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
it('is undefined when not supplied, so no window is sent at all', () => {
|
|
161
|
+
expect(parseSinceToSeconds(undefined)).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('toTimestampSortKey', () => {
|
|
166
|
+
it.each([
|
|
167
|
+
['2026-07-17T10:12:25.12Z', '2026-07-17T10:12:25.120000000Z'],
|
|
168
|
+
['2026-07-17T10:12:25.1201Z', '2026-07-17T10:12:25.120100000Z'],
|
|
169
|
+
['2026-07-17T10:12:25Z', '2026-07-17T10:12:25.000000000Z'],
|
|
170
|
+
['2026-07-17T10:12:25.130421355Z', '2026-07-17T10:12:25.130421355Z'],
|
|
171
|
+
])('pads %s to %s', (input, expected) => {
|
|
172
|
+
expect(toTimestampSortKey(input)).toBe(expected);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('leaves a value it cannot parse untouched', () => {
|
|
176
|
+
expect(toTimestampSortKey('not-a-timestamp')).toBe('not-a-timestamp');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { getServiceAccountToken } from '@axinom/mosaic-id-link-be';
|
|
2
|
+
import { isNullOrWhitespace } from '@axinom/mosaic-service-common';
|
|
3
|
+
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { exitCode } from '../../../../common/index.js';
|
|
6
|
+
import type { ServiceLogsOptions } from './service-logs-options.js';
|
|
7
|
+
|
|
8
|
+
const { red, dim } = chalk;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The GraphQL shape returned by Hosting, which is deliberately declared here
|
|
12
|
+
* rather than imported: this package is published, and the SDU packages that
|
|
13
|
+
* own the equivalent REST DTO are private.
|
|
14
|
+
*/
|
|
15
|
+
export interface ServiceReplicaLog {
|
|
16
|
+
replicaName: string;
|
|
17
|
+
lines: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Log lines go to stdout and nothing else does; status messages and errors go
|
|
22
|
+
* to stderr. This keeps `mosaic hosting service logs -i svc > out.log` a file
|
|
23
|
+
* of pure log output, and keeps `| grep` results free of decoration.
|
|
24
|
+
*/
|
|
25
|
+
const writeLogLine = (line: string): void => {
|
|
26
|
+
process.stdout.write(line + '\n');
|
|
27
|
+
};
|
|
28
|
+
const writeStatus = (message: string): void => {
|
|
29
|
+
process.stderr.write(message + '\n');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const getAxiosInstance = (
|
|
33
|
+
hostingServiceBaseUrl: string,
|
|
34
|
+
serviceAccountToken: string,
|
|
35
|
+
): AxiosInstance => {
|
|
36
|
+
return axios.create({
|
|
37
|
+
baseURL: new URL(hostingServiceBaseUrl).toString(),
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${serviceAccountToken}`,
|
|
40
|
+
'content-type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Replica names are full K8s pod names, e.g.
|
|
47
|
+
* `d-8ip51mklekzyxmtk-5ihjg3ycvjkpnmb2-ax-media-service-bb9c8cfmfq`. Everything
|
|
48
|
+
* up to and including the service ID is identical across replicas of the
|
|
49
|
+
* service that was asked for, so only the trailing segment carries
|
|
50
|
+
* information -- and 60+ characters in front of every line is unreadable.
|
|
51
|
+
* `--json` keeps the full name.
|
|
52
|
+
*/
|
|
53
|
+
export const toShortReplicaName = (
|
|
54
|
+
replicaName: string,
|
|
55
|
+
serviceId: string,
|
|
56
|
+
): string => {
|
|
57
|
+
const marker = `-${serviceId}-`;
|
|
58
|
+
const index = replicaName.lastIndexOf(marker);
|
|
59
|
+
if (index === -1) {
|
|
60
|
+
return replicaName;
|
|
61
|
+
}
|
|
62
|
+
const suffix = replicaName.slice(index + marker.length);
|
|
63
|
+
return suffix.length > 0 ? suffix : replicaName;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Drops the empty element left by the log's trailing newline, while preserving
|
|
68
|
+
* blank lines inside the log: a service not using
|
|
69
|
+
* `@axinom/mosaic-service-common` may emit plain multi-line text where blank
|
|
70
|
+
* lines are meaningful.
|
|
71
|
+
*/
|
|
72
|
+
const stripTrailingBlank = (lines: string[]): string[] => {
|
|
73
|
+
const result = [...lines];
|
|
74
|
+
while (result.length > 0 && result[result.length - 1] === '') {
|
|
75
|
+
result.pop();
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const SINCE_UNITS_IN_SECONDS: Record<string, number> = {
|
|
81
|
+
s: 1,
|
|
82
|
+
m: 60,
|
|
83
|
+
h: 60 * 60,
|
|
84
|
+
d: 24 * 60 * 60,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const SINCE_PATTERN = /^(\d+)(s|m|h|d)?$/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reads an age such as `30s`, `5m`, `2h` or `1d` into seconds, matching how
|
|
91
|
+
* `kubectl logs --since` and `docker logs --since` are written. A bare number
|
|
92
|
+
* is read as seconds. Returns undefined when the value cannot be read.
|
|
93
|
+
*/
|
|
94
|
+
export const parseSinceToSeconds = (
|
|
95
|
+
since: string | undefined,
|
|
96
|
+
): number | undefined => {
|
|
97
|
+
if (since === undefined) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const match = SINCE_PATTERN.exec(since.trim());
|
|
101
|
+
if (!match) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const amount = Number(match[1]);
|
|
105
|
+
if (amount <= 0) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
return amount * SINCE_UNITS_IN_SECONDS[match[2] ?? 's'];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
interface OrderedLine {
|
|
112
|
+
/** RFC3339 timestamp Kubernetes recorded for the line, used for ordering. */
|
|
113
|
+
sortKey: string;
|
|
114
|
+
replicaName: string;
|
|
115
|
+
timestamp: string;
|
|
116
|
+
message: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\S+Z) (.*)$/s;
|
|
120
|
+
|
|
121
|
+
const NANO_DIGITS = 9;
|
|
122
|
+
const RFC3339_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pads the fractional seconds to a fixed width so timestamps can be ordered by
|
|
126
|
+
* plain string comparison.
|
|
127
|
+
*
|
|
128
|
+
* RFC3339Nano permits a variable-length fraction, and comparing those directly
|
|
129
|
+
* misorders them: `.12Z` is earlier than `.1201Z`, but sorts after it because
|
|
130
|
+
* `Z` compares above `0`. Padding avoids that while keeping the precision a
|
|
131
|
+
* `Date` would round away. A value that does not parse is returned unchanged
|
|
132
|
+
* and simply orders lexicographically.
|
|
133
|
+
*/
|
|
134
|
+
export const toTimestampSortKey = (timestamp: string): string => {
|
|
135
|
+
const match = RFC3339_PATTERN.exec(timestamp);
|
|
136
|
+
if (!match) {
|
|
137
|
+
return timestamp;
|
|
138
|
+
}
|
|
139
|
+
return `${match[1]}.${(match[2] ?? '').padEnd(NANO_DIGITS, '0')}Z`;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Splits the timestamp Kubernetes prefixed onto each line from the line itself.
|
|
144
|
+
*
|
|
145
|
+
* A line that does not carry one (which should not happen, but a malformed or
|
|
146
|
+
* truncated log could produce it) inherits the previous line's key so it stays
|
|
147
|
+
* next to the line it belongs with, instead of sorting to the top.
|
|
148
|
+
*/
|
|
149
|
+
const toOrderedLines = (
|
|
150
|
+
replicaName: string,
|
|
151
|
+
lines: string[],
|
|
152
|
+
previousKey = '',
|
|
153
|
+
): OrderedLine[] => {
|
|
154
|
+
let lastKey = previousKey;
|
|
155
|
+
return lines.map((line) => {
|
|
156
|
+
const match = TIMESTAMP_PATTERN.exec(line);
|
|
157
|
+
if (match) {
|
|
158
|
+
lastKey = toTimestampSortKey(match[1]);
|
|
159
|
+
return {
|
|
160
|
+
sortKey: lastKey,
|
|
161
|
+
replicaName,
|
|
162
|
+
timestamp: match[1],
|
|
163
|
+
message: match[2],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { sortKey: lastKey, replicaName, timestamp: '', message: line };
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Merges every replica's lines into one chronological stream.
|
|
172
|
+
*
|
|
173
|
+
* The keys are RFC3339 UTC strings of identical shape, so comparing them as
|
|
174
|
+
* strings orders them correctly and preserves the sub-millisecond precision
|
|
175
|
+
* that parsing into a `Date` would discard. Sorting is stable, so lines sharing
|
|
176
|
+
* a timestamp keep their original per-replica order.
|
|
177
|
+
*/
|
|
178
|
+
export const mergeReplicaLines = (
|
|
179
|
+
replicas: ServiceReplicaLog[],
|
|
180
|
+
): OrderedLine[] =>
|
|
181
|
+
replicas
|
|
182
|
+
.flatMap((replica) =>
|
|
183
|
+
toOrderedLines(replica.replicaName, stripTrailingBlank(replica.lines)),
|
|
184
|
+
)
|
|
185
|
+
.sort((a, b) =>
|
|
186
|
+
a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
export const getServiceLogs = async (
|
|
190
|
+
args: Required<Pick<ServiceLogsOptions, 'serviceId'>> & ServiceLogsOptions,
|
|
191
|
+
): Promise<void> => {
|
|
192
|
+
const serviceAccountToken = await getServiceAccountToken(
|
|
193
|
+
args.idServiceAuthBaseURL as string,
|
|
194
|
+
args.mosaicHostingClientId as string,
|
|
195
|
+
args.mosaicHostingClientSecret as string,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const axiosInstance: AxiosInstance = getAxiosInstance(
|
|
199
|
+
args.hostingServiceBaseURL as string,
|
|
200
|
+
serviceAccountToken.accessToken,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const query = `query GetServiceRuntimeLogs($serviceId: String!, $tailLines: Int, $sinceSeconds: Int) {
|
|
204
|
+
serviceRuntimeLogs(input: { serviceId: $serviceId, tailLines: $tailLines, sinceSeconds: $sinceSeconds }) {
|
|
205
|
+
replicas {
|
|
206
|
+
replicaName
|
|
207
|
+
lines
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}`;
|
|
211
|
+
|
|
212
|
+
const payload = JSON.stringify({
|
|
213
|
+
query,
|
|
214
|
+
variables: {
|
|
215
|
+
serviceId: args.serviceId,
|
|
216
|
+
tailLines: args.tail,
|
|
217
|
+
sinceSeconds: parseSinceToSeconds(args.since),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const response = await axiosInstance.post('graphql', payload);
|
|
223
|
+
|
|
224
|
+
if (response.data.errors !== undefined && response.data.errors.length > 0) {
|
|
225
|
+
writeStatus(
|
|
226
|
+
red(`Failed to retrieve logs for service [${args.serviceId}].`),
|
|
227
|
+
);
|
|
228
|
+
writeStatus(red(response.data.errors[0].message));
|
|
229
|
+
process.exit(exitCode);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const replicas: ServiceReplicaLog[] =
|
|
233
|
+
response.data.data?.serviceRuntimeLogs?.replicas ?? [];
|
|
234
|
+
|
|
235
|
+
const merged = mergeReplicaLines(replicas);
|
|
236
|
+
|
|
237
|
+
// Newline-delimited JSON: the same ordering as the human output, one object
|
|
238
|
+
// per line so it streams into `jq` and friends. The replica and timestamp
|
|
239
|
+
// are always present as fields, which is why --prefix and --timestamps do
|
|
240
|
+
// not apply here.
|
|
241
|
+
if (args.json) {
|
|
242
|
+
for (const line of merged) {
|
|
243
|
+
writeLogLine(
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
replicaName: line.replicaName,
|
|
246
|
+
timestamp: line.timestamp,
|
|
247
|
+
message: line.message,
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (replicas.length === 0) {
|
|
255
|
+
writeStatus(
|
|
256
|
+
dim(`No running replicas found for service [${args.serviceId}].`),
|
|
257
|
+
);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// With one replica the prefix is noise on every line; with several it is
|
|
262
|
+
// the only way to tell which replica said what.
|
|
263
|
+
const usePrefix = args.prefix ?? replicas.length > 1;
|
|
264
|
+
|
|
265
|
+
writeStatus(
|
|
266
|
+
dim(
|
|
267
|
+
`# ${args.serviceId}: ${replicas.length} replica(s)${
|
|
268
|
+
args.tail ? `, last ${args.tail} line(s) each` : ''
|
|
269
|
+
}`,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
for (const line of merged) {
|
|
274
|
+
// The service's own text is emitted verbatim: it may be plain text
|
|
275
|
+
// rather than JSON, and any reformatting would corrupt it.
|
|
276
|
+
const prefix = usePrefix
|
|
277
|
+
? `[${toShortReplicaName(line.replicaName, args.serviceId)}] `
|
|
278
|
+
: '';
|
|
279
|
+
const stamp =
|
|
280
|
+
args.timestamps && line.timestamp ? `${line.timestamp} ` : '';
|
|
281
|
+
writeLogLine(`${prefix}${stamp}${line.message}`);
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const axiosError = error as AxiosError;
|
|
285
|
+
writeStatus(
|
|
286
|
+
red(`Error while retrieving logs for service [${args.serviceId}].`),
|
|
287
|
+
);
|
|
288
|
+
writeStatus(red(`Error: ${axiosError.message}`));
|
|
289
|
+
|
|
290
|
+
if (axiosError.response) {
|
|
291
|
+
writeStatus(red(`Status: ${axiosError.response.status}`));
|
|
292
|
+
writeStatus(
|
|
293
|
+
red(`Response: ${JSON.stringify(axiosError.response.data, null, 2)}`),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
process.exit(exitCode);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Validate arguments for reading service logs.
|
|
302
|
+
*/
|
|
303
|
+
export const validateServiceLogsArgs = (
|
|
304
|
+
args: ServiceLogsOptions,
|
|
305
|
+
): [ServiceLogsOptions, string[]] => {
|
|
306
|
+
const errorMessages: string[] = [];
|
|
307
|
+
|
|
308
|
+
const idServiceAuthBaseURL =
|
|
309
|
+
args.idServiceAuthBaseURL ?? process.env.ID_SERVICE_AUTH_BASE_URL ?? '';
|
|
310
|
+
const mosaicHostingClientId =
|
|
311
|
+
args.mosaicHostingClientId ?? process.env.MOSAIC_HOSTING_CLIENT_ID ?? '';
|
|
312
|
+
const mosaicHostingClientSecret =
|
|
313
|
+
args.mosaicHostingClientSecret ??
|
|
314
|
+
process.env.MOSAIC_HOSTING_CLIENT_SECRET ??
|
|
315
|
+
'';
|
|
316
|
+
const hostingServiceBaseURL =
|
|
317
|
+
args.hostingServiceBaseURL ?? process.env.HOSTING_SERVICE_BASE_URL ?? '';
|
|
318
|
+
|
|
319
|
+
if (isNullOrWhitespace(idServiceAuthBaseURL)) {
|
|
320
|
+
errorMessages.push('[idServiceAuthBaseURL] is required.');
|
|
321
|
+
}
|
|
322
|
+
if (isNullOrWhitespace(mosaicHostingClientId)) {
|
|
323
|
+
errorMessages.push('[mosaicHostingClientId] is required.');
|
|
324
|
+
}
|
|
325
|
+
if (isNullOrWhitespace(mosaicHostingClientSecret)) {
|
|
326
|
+
errorMessages.push('[mosaicHostingClientSecret] is required.');
|
|
327
|
+
}
|
|
328
|
+
if (isNullOrWhitespace(hostingServiceBaseURL)) {
|
|
329
|
+
errorMessages.push('[hostingServiceBaseURL] is required.');
|
|
330
|
+
}
|
|
331
|
+
if (isNullOrWhitespace(args.serviceId)) {
|
|
332
|
+
errorMessages.push('[serviceId] is required.');
|
|
333
|
+
}
|
|
334
|
+
if (args.tail !== undefined && (args.tail < 1 || args.tail > 1000)) {
|
|
335
|
+
errorMessages.push('[tail] must be between 1 and 1000.');
|
|
336
|
+
}
|
|
337
|
+
if (
|
|
338
|
+
args.since !== undefined &&
|
|
339
|
+
parseSinceToSeconds(args.since) === undefined
|
|
340
|
+
) {
|
|
341
|
+
errorMessages.push(
|
|
342
|
+
'[since] must be a positive age such as 30s, 5m, 2h or 1d.',
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return [
|
|
347
|
+
{
|
|
348
|
+
...args,
|
|
349
|
+
idServiceAuthBaseURL,
|
|
350
|
+
mosaicHostingClientId,
|
|
351
|
+
mosaicHostingClientSecret,
|
|
352
|
+
hostingServiceBaseURL,
|
|
353
|
+
},
|
|
354
|
+
errorMessages,
|
|
355
|
+
];
|
|
356
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CommandModule } from 'yargs';
|
|
2
2
|
import { serviceDeploy } from './deploy/service-deploy-command.js';
|
|
3
|
+
import { serviceLogs } from './runtime-logs/service-logs-command.js';
|
|
3
4
|
import { serviceUndeploy } from './undeploy/service-undeploy-command.js';
|
|
4
5
|
|
|
5
6
|
export const serviceCommands: CommandModule<unknown, unknown> = {
|
|
@@ -10,12 +11,16 @@ export const serviceCommands: CommandModule<unknown, unknown> = {
|
|
|
10
11
|
'Deploy a service through Hosting Service.',
|
|
11
12
|
serviceDeploy,
|
|
12
13
|
)
|
|
13
|
-
.demandCommand()
|
|
14
14
|
.command(
|
|
15
15
|
'undeploy',
|
|
16
16
|
'Undeploy a service through Hosting Service.',
|
|
17
17
|
serviceUndeploy,
|
|
18
18
|
)
|
|
19
|
+
.command(
|
|
20
|
+
'logs',
|
|
21
|
+
'Read the runtime logs of a deployed service.',
|
|
22
|
+
serviceLogs,
|
|
23
|
+
)
|
|
19
24
|
.demandCommand(),
|
|
20
25
|
handler: () => {
|
|
21
26
|
console.log('Please pick an action related to hosting service command.');
|