Compare commits

...

46 Commits

Author SHA1 Message Date
1671e46d99 v16.7.9 2023-08-22 13:33:30 +00:00
507333c463 Merge pull request #2663 from balena-io/bumps-sdk-to-v18
Update balena-sdk to v18
2023-08-22 10:32:34 -03:00
8b320d3e9e Update balena-sdk to v18
Update balena-sdk from 17.21.1 to 18.0.0
Update balena-preload from 14.0.0 to 14.0.2
Update balena-image-manager from 9.0.0 to 9.0.2
Change-type: patch
2023-08-22 09:38:39 -03:00
e1be268749 v16.7.8 2023-08-22 11:16:38 +00:00
1a0019e6d0 Merge pull request #2662 from balena-io/otaviojacobi/bumps-to-sdk-17-12-1
Update balena-sdk to ^17.12.1
2023-08-22 11:15:33 +00:00
e79cdb671f Update balena-settings-storage to 8.1.0
Update balena-settings-storage from 7.0.0 to 8.1.0
Change-type: patch
2023-08-21 14:42:28 -03:00
f38e643cf0 env: Stop fetching unnecessary app fields
Change-type: patch
2023-08-21 14:39:16 -03:00
b8e190cd1d Remove redundant envs documentation
Change-type: patch
2023-08-21 14:39:16 -03:00
9cca654bd5 Update balena-sdk to 17.12.1
Update balena-sdk from 17.8.0 to 17.12.1
Change-type: patch
2023-08-21 14:39:16 -03:00
35177e2d2f v16.7.7 2023-08-21 16:56:33 +00:00
1a24b193e7 Merge pull request #2664 from balena-io/otavio-revert-flowzone-to-master
Revert flowzone to master
2023-08-21 16:55:31 +00:00
272915192b Revert flowzone to master
Change-type: patch
2023-08-17 20:22:53 -03:00
96774f4c52 v16.7.6 2023-07-24 13:38:06 +00:00
a034f585ba Merge pull request #2656 from balena-io/fix-app-create
app create: Fix halting with a deprecation warning
2023-07-24 13:37:00 +00:00
365d95c36b app create: Fix halting with a deprecation warning
Change-type: patch
2023-07-21 10:02:32 +03:00
c6313c08ae v16.7.5 2023-07-21 06:59:22 +00:00
f5764c4659 Merge pull request #2655 from balena-io/abstract-fleet-app-block-create
Abstract the fleet/app/block create commands
2023-07-21 09:58:36 +03:00
aff094575b Abstract the fleet/app/block create commands
Change-type: patch
2023-07-20 16:14:52 +03:00
4aaaf64f8d v16.7.4 2023-07-20 10:41:09 +00:00
7b88ce273f Merge pull request #2654 from balena-io/move-discontinued-dt
move: Include fleets of discontinued device types in the fleet selection
2023-07-20 10:40:12 +00:00
b011af89ad move: Include fleets of discontinued device types in the fleet selection
Change-type: patch
2023-07-20 13:03:54 +03:00
1bf8c1bfe7 v16.7.3 2023-07-20 08:30:09 +00:00
2b39d5d111 Merge pull request #2653 from balena-io/promote-discontinued-dt
promote: Allow joining fleets of discontinued device types
2023-07-20 08:29:20 +00:00
98663af7f6 Rerun npm-shrinkwrap.json deduplication 2023-07-19 19:44:43 +03:00
5628824bee promote: Allow joining fleets of discontinued device types
Change-type: patch
2023-07-19 19:17:27 +03:00
d12d7996bc v16.7.2 2023-07-19 01:43:55 +00:00
0dcf4cbff6 Merge pull request #2650 from balena-io/bump-balena-compose
Update balena-compose to v3.0.2
2023-07-19 01:42:56 +00:00
884e37d242 Update balena-compose to v3.0.2
Update balena-compose to v3.0.2

That release removes the use of the `cachefrom` on pull tasks, which
there is good evidence to suggest is the cause of #2165

Change-type: patch
2023-07-18 18:14:56 -04:00
f4a24e26c3 v16.7.1 2023-07-18 20:27:07 +00:00
122eccf3dc Merge pull request #2652 from balena-io/update-balena-sdk-17.8.0
Update balena-sdk to 17.8.0
2023-07-18 20:26:16 +00:00
bd598788dc Update balena-sdk to 17.8.0
Update balena-sdk from 17.0.0 to 17.8.0

Change-type: patch
2023-07-18 22:48:54 +03:00
406482b4da v16.7.0 2023-07-17 19:59:52 +00:00
a381c97ca9 Merge pull request #2649 from balena-io/preload-no-pin-device-to-release
preload: Add the --no-pin-device-to-release flag to avoid interactive questions
2023-07-17 19:59:04 +00:00
8ce78ba33c Rerun npm-shrinkwrap.json deduplication 2023-07-17 11:25:02 +03:00
f53f148c89 preload: Add the --no-pin-device-to-release flag to avoid interactive questions
Change-type: minor
See: https://balena.zulipchat.com/#narrow/stream/345746-aspect.2Fproduct/topic/Level.20-.20need.20thoughts.20on.20questions.20.26.20feature.20suggestions
2023-07-17 11:19:03 +03:00
0086feb645 v16.6.6 2023-07-10 17:16:08 +00:00
4ee55b049f Merge pull request #2646 from balena-io/reduce-lodash-usage
Reduce lodash usage in common user interaction patterns
2023-07-10 17:15:14 +00:00
90c6f121cc Rerun npm-shrinkwrap.json deduplication 2023-07-10 19:36:36 +03:00
d3c27ae859 Reduce lodash usage in common user interaction patterns
Change-type: patch
2023-07-10 17:19:01 +03:00
8f39c1de6c v16.6.5 2023-07-09 21:29:55 +00:00
4df1831187 Merge pull request #2645 from balena-io/application-create-hostApp-based-supported-DTs
fleet/block/app create: Fetch the supported device types using the hostApps
2023-07-09 21:29:02 +00:00
2bce761ace Rerun npm-shrinkwrap.json deduplication 2023-07-07 20:17:53 +03:00
d78b76aceb fleet/block/app create: Fetch the supported device types using the hostApps
Change-type: patch
See: https://balena.zulipchat.com/#narrow/stream/360838-balena-io.2Fos.2Fdevices/topic/state.20field.20in.20device-type.2Ejson
See: https://balena.fibery.io/Organisation/Improvements-849#Improvements/Stop-relying-on-device-types-v1-device-type.json-for-unrelated-things-257
2023-07-07 19:57:36 +03:00
f07f6b84d4 v16.6.4 2023-07-06 13:58:27 +00:00
d297a10570 Merge pull request #2643 from balena-io/bump-balena-compose
Bump balena-compose to v2.3.0
2023-07-06 13:57:37 +00:00
9d0b82122a Bump balena-compose to v2.3.0
This allows the the CLI to use docker registry config when querying the
images manifest.

Relates-to: balena-io-modules/balena-compose#31
Change-type: patch
2023-07-05 15:46:42 -04:00
35 changed files with 6231 additions and 717 deletions

4
.gitattributes vendored
View File

@ -4,6 +4,10 @@
*.* -eol
*.sh text eol=lf
.dockerignore eol=lf
Dockerfile eol=lf
Dockerfile.* eol=lf
* text=auto eol=lf
# lf for the docs as it's auto-generated and will otherwise trigger an uncommited error on windows
docs/balena-cli.md text eol=lf

View File

@ -49,7 +49,7 @@ runs:
- name: Compress custom source
shell: pwsh
run: tar -acf ${{ runner.temp }}/custom.tgz .
run: tar --exclude-vcs -acf ${{ runner.temp }}/custom.tgz .
- name: Upload custom artifact
uses: actions/upload-artifact@v3

View File

@ -11,7 +11,7 @@ on:
jobs:
flowzone:
name: Flowzone
uses: product-os/flowzone/.github/workflows/flowzone.yml@v4.7.1
uses: product-os/flowzone/.github/workflows/flowzone.yml@master
# prevent duplicate workflow executions for pull_request and pull_request_target
if: |
(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1276,7 +1276,6 @@ Examples:
$ balena envs --fleet myorg/myfleet
$ balena envs --fleet MyFleet --json
$ balena envs --fleet MyFleet --service MyService
$ balena envs --fleet MyFleet --service MyService
$ balena envs --fleet MyFleet --config
$ balena envs --device 7cf02a6
$ balena envs --device 7cf02a6 --json

View File

@ -62,7 +62,7 @@ export default class ApiKeysCmd extends Command {
$select: 'actor',
})
).actor
: await getBalenaSdk().auth.getUserActorId();
: await getBalenaSdk().auth.getActorId();
const keys = await getBalenaSdk().pine.get({
resource: 'api_key',
options: {

View File

@ -18,19 +18,9 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
interface FlagsDef {
organization?: string;
type?: string; // application device type
help: void;
}
interface ArgsDef {
name: string;
}
import { stripIndent } from '../../utils/lazy';
import { ArgsDef, FlagsDef } from '../../utils/application-create';
export default class AppCreateCmd extends Command {
public static description = stripIndent`
@ -90,61 +80,8 @@ export default class AppCreateCmd extends Command {
AppCreateCmd,
);
// Ascertain device type
const deviceType =
options.type ||
(await (await import('../../utils/patterns')).selectDeviceType());
// Ascertain organization
const organization =
options.organization?.toLowerCase() || (await this.getOrganization());
// Create application
try {
const application = await getBalenaSdk().models.application.create({
name: params.name,
deviceType,
organization,
applicationClass: 'app',
});
// Output
console.log(
`App created: slug "${application.slug}", device type "${deviceType}"`,
);
} catch (err) {
if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
throw new ExpectedError(
`Error: An app or block or fleet with the name "${params.name}" already exists in organization "${organization}".`,
);
} else if ((err.message || '').toLowerCase().includes('unauthorized')) {
// BalenaRequestError: Request error: Unauthorized
throw new ExpectedError(
`Error: You are not authorized to create apps in organization "${organization}".`,
);
}
throw err;
}
}
async getOrganization() {
const { getOwnOrganizations } = await import('../../utils/sdk');
const organizations = await getOwnOrganizations(getBalenaSdk(), {
$select: ['name', 'handle'],
});
if (organizations.length === 0) {
// User is not a member of any organizations (should not happen).
throw new Error('This account is not a member of any organizations');
} else if (organizations.length === 1) {
// User is a member of only one organization - use this.
return organizations[0].handle;
} else {
// User is a member of multiple organizations -
const { selectOrganization } = await import('../../utils/patterns');
return selectOrganization(organizations);
}
await (
await import('../../utils/application-create')
).applicationCreateBase('app', options, params);
}
}

View File

@ -18,9 +18,8 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { stripIndent } from '../../utils/lazy';
interface FlagsDef {
organization?: string;
@ -90,61 +89,8 @@ export default class BlockCreateCmd extends Command {
BlockCreateCmd,
);
// Ascertain device type
const deviceType =
options.type ||
(await (await import('../../utils/patterns')).selectDeviceType());
// Ascertain organization
const organization =
options.organization?.toLowerCase() || (await this.getOrganization());
// Create application
try {
const application = await getBalenaSdk().models.application.create({
name: params.name,
deviceType,
organization,
applicationClass: 'block',
});
// Output
console.log(
`Block created: slug "${application.slug}", device type "${deviceType}"`,
);
} catch (err) {
if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
throw new ExpectedError(
`Error: An app or block or fleet with the name "${params.name}" already exists in organization "${organization}".`,
);
} else if ((err.message || '').toLowerCase().includes('unauthorized')) {
// BalenaRequestError: Request error: Unauthorized
throw new ExpectedError(
`Error: You are not authorized to create blocks in organization "${organization}".`,
);
}
throw err;
}
}
async getOrganization() {
const { getOwnOrganizations } = await import('../../utils/sdk');
const organizations = await getOwnOrganizations(getBalenaSdk(), {
$select: ['name', 'handle'],
});
if (organizations.length === 0) {
// User is not a member of any organizations (should not happen).
throw new Error('This account is not a member of any organizations');
} else if (organizations.length === 1) {
// User is a member of only one organization - use this.
return organizations[0].handle;
} else {
// User is a member of multiple organizations -
const { selectOrganization } = await import('../../utils/patterns');
return selectOrganization(organizations);
}
await (
await import('../../utils/application-create')
).applicationCreateBase('block', options, params);
}
}

View File

@ -346,9 +346,9 @@ ${dockerignoreHelp}
);
logger.logWarn(msg);
const [token, username, url, options] = await Promise.all([
const [token, { username }, url, options] = await Promise.all([
sdk.auth.getToken(),
sdk.auth.whoami(),
sdk.auth.getUserInfo(),
sdk.settings.get('balenaUrl'),
{
// opts.appName may be prefixed by 'owner/', unlike opts.app.app_name
@ -371,8 +371,8 @@ ${dockerignoreHelp}
$select: ['commit'],
});
} else {
const [userId, auth, apiEndpoint] = await Promise.all([
sdk.auth.getUserId(),
const [{ id: userId }, auth, apiEndpoint] = await Promise.all([
sdk.auth.getUserInfo(),
sdk.auth.getToken(),
sdk.settings.get('apiUrl'),
]);

View File

@ -20,7 +20,6 @@ import type { IArg } from '@oclif/parser/lib/args';
import type {
BalenaSDK,
Device,
DeviceType,
PineOptions,
PineTypedResult,
} from 'balena-sdk';
@ -138,7 +137,6 @@ export default class DeviceMoveCmd extends Command {
balena: BalenaSDK,
devices: Awaited<ReturnType<typeof this.getDevices>>,
) {
const { getExpandedProp } = await import('../../utils/pine');
// deduplicate the slugs
const deviceCpuArchs = Array.from(
new Set(
@ -148,48 +146,44 @@ export default class DeviceMoveCmd extends Command {
),
);
const deviceTypeOptions = {
$select: 'slug',
$expand: {
is_of__cpu_architecture: {
$select: 'slug',
},
const allCpuArches = await balena.pine.get({
resource: 'cpu_architecture',
options: {
$select: ['id', 'slug'],
},
} satisfies PineOptions<DeviceType>;
const deviceTypes = (await balena.models.deviceType.getAllSupported(
deviceTypeOptions,
)) as Array<PineTypedResult<DeviceType, typeof deviceTypeOptions>>;
});
const compatibleDeviceTypeSlugs = new Set(
deviceTypes
.filter((deviceType) => {
const deviceTypeArch = getExpandedProp(
deviceType.is_of__cpu_architecture,
'slug',
)!;
return deviceCpuArchs.every((deviceCpuArch) =>
balena.models.os.isArchitectureCompatibleWith(
deviceCpuArch,
deviceTypeArch,
),
);
})
.map((deviceType) => deviceType.slug),
);
const compatibleCpuArchIds = allCpuArches
.filter((cpuArch) => {
return deviceCpuArchs.every((deviceCpuArch) =>
balena.models.os.isArchitectureCompatibleWith(
deviceCpuArch,
cpuArch.slug,
),
);
})
.map((deviceType) => deviceType.id);
const patterns = await import('../../utils/patterns');
try {
const application = await patterns.selectApplication(
(app) =>
compatibleDeviceTypeSlugs.has(app.is_for__device_type[0].slug) &&
devices.some(
(device) => device.belongs_to__application.__id !== app.id,
),
{
is_for__device_type: {
$any: {
$alias: 'dt',
$expr: {
dt: {
is_of__cpu_architecture: { $in: compatibleCpuArchIds },
},
},
},
},
},
true,
);
return application;
} catch (err) {
if (!compatibleDeviceTypeSlugs.size) {
if (!compatibleCpuArchIds.length) {
throw new ExpectedError(
`${err.message}\nDo all devices have a compatible architecture?`,
);

View File

@ -268,6 +268,7 @@ async function getServiceIdForApp(
): Promise<number> {
let serviceId: number | undefined;
const services = await sdk.models.service.getAllByApplication(appSlug, {
$select: 'id',
$filter: { service_name: serviceName },
});
if (services.length > 0) {

View File

@ -93,7 +93,6 @@ export default class EnvsCmd extends Command {
'$ balena envs --fleet myorg/myfleet',
'$ balena envs --fleet MyFleet --json',
'$ balena envs --fleet MyFleet --service MyService',
'$ balena envs --fleet MyFleet --service MyService',
'$ balena envs --fleet MyFleet --config',
'$ balena envs --device 7cf02a6',
'$ balena envs --device 7cf02a6 --json',
@ -209,6 +208,7 @@ async function validateServiceName(
fleetSlug: string,
) {
const services = await sdk.models.service.getAllByApplication(fleetSlug, {
$select: 'id',
$filter: { service_name: serviceName },
});
if (services.length === 0) {

View File

@ -18,19 +18,9 @@
import { flags } from '@oclif/command';
import Command from '../../command';
import { ExpectedError } from '../../errors';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
interface FlagsDef {
organization?: string;
type?: string; // application device type
help: void;
}
interface ArgsDef {
name: string;
}
import { stripIndent } from '../../utils/lazy';
import { ArgsDef, FlagsDef } from '../../utils/application-create';
export default class FleetCreateCmd extends Command {
public static description = stripIndent`
@ -90,60 +80,8 @@ export default class FleetCreateCmd extends Command {
FleetCreateCmd,
);
// Ascertain device type
const deviceType =
options.type ||
(await (await import('../../utils/patterns')).selectDeviceType());
// Ascertain organization
const organization =
options.organization?.toLowerCase() || (await this.getOrganization());
// Create application
try {
const application = await getBalenaSdk().models.application.create({
name: params.name,
deviceType,
organization,
});
// Output
console.log(
`Fleet created: slug "${application.slug}", device type "${deviceType}"`,
);
} catch (err) {
if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
throw new ExpectedError(
`Error: An app or block or fleet with the name "${params.name}" already exists in organization "${organization}".`,
);
} else if ((err.message || '').toLowerCase().includes('unauthorized')) {
// BalenaRequestError: Request error: Unauthorized
throw new ExpectedError(
`Error: You are not authorized to create fleets in organization "${organization}".`,
);
}
throw err;
}
}
async getOrganization() {
const { getOwnOrganizations } = await import('../../utils/sdk');
const organizations = await getOwnOrganizations(getBalenaSdk(), {
$select: ['name', 'handle'],
});
if (organizations.length === 0) {
// User is not a member of any organizations (should not happen).
throw new Error('This account is not a member of any organizations');
} else if (organizations.length === 1) {
// User is a member of only one organization - use this.
return organizations[0].handle;
} else {
// User is a member of multiple organizations -
const { selectOrganization } = await import('../../utils/patterns');
return selectOrganization(organizations);
}
await (
await import('../../utils/application-create')
).applicationCreateBase('fleet', options, params);
}
}

View File

@ -15,6 +15,7 @@
* limitations under the License.
*/
import type * as BalenaSdk from 'balena-sdk';
import { flags } from '@oclif/command';
import Command from '../command';
@ -22,7 +23,7 @@ import * as cf from '../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../utils/lazy';
import type { DataSetOutputOptions } from '../framework';
interface ExtendedApplication extends ApplicationWithDeviceType {
interface ExtendedApplication extends ApplicationWithDeviceTypeSlug {
device_count: number;
online_devices: number;
device_type?: string;
@ -60,15 +61,20 @@ export default class FleetsCmd extends Command {
const balena = getBalenaSdk();
const pineOptions = {
$select: ['id', 'app_name', 'slug'],
$expand: {
is_for__device_type: { $select: 'slug' },
owns__device: { $select: 'is_online' },
},
} satisfies BalenaSdk.PineOptions<BalenaSdk.Application>;
// Get applications
const applications =
(await balena.models.application.getAllDirectlyAccessible({
$select: ['id', 'app_name', 'slug'],
$expand: {
is_for__device_type: { $select: 'slug' },
owns__device: { $select: 'is_online' },
},
})) as ExtendedApplication[];
(await balena.models.application.getAllDirectlyAccessible(
pineOptions,
)) as Array<
BalenaSdk.PineTypedResult<BalenaSdk.Application, typeof pineOptions>
> as ExtendedApplication[];
// Add extended properties
applications.forEach((application) => {

View File

@ -137,7 +137,7 @@ export default class LoginCmd extends Command {
console.log(`\nLogging in to ${balenaUrl}`);
await this.doLogin(options, balenaUrl, params.token);
const username = await balena.auth.whoami();
const { username } = await balena.auth.getUserInfo();
console.info(`Successfully logged in as: ${username}`);
console.info(`\
@ -165,7 +165,12 @@ ${messages.reachingOut}`);
}
const balena = getBalenaSdk();
await balena.auth.loginWithToken(token!);
if (!(await balena.auth.whoami())) {
try {
await balena.auth.getUserInfo();
} catch (err) {
if (process.env.DEBUG) {
console.error(`Get user info failed with: ${err.message}`);
}
throw new ExpectedError('Token authentication failed');
}
return;

View File

@ -204,7 +204,7 @@ export default class OsConfigureCmd extends Command {
const helpers = await import('../../utils/helpers');
const { getApplication } = await import('../../utils/sdk');
let app: ApplicationWithDeviceType | undefined;
let app: ApplicationWithDeviceTypeSlug | undefined;
let device;
let deviceTypeSlug: string;
@ -223,7 +223,7 @@ export default class OsConfigureCmd extends Command {
$expand: {
is_for__device_type: { $select: 'slug' },
},
})) as ApplicationWithDeviceType;
})) as ApplicationWithDeviceTypeSlug;
await checkDeviceTypeCompatibility(options, app);
deviceTypeSlug =
options['device-type'] || app.is_for__device_type[0].slug;

View File

@ -46,7 +46,7 @@ interface FlagsDef extends DockerConnectionCliFlags {
commit?: string;
'splash-image'?: string;
'dont-check-arch': boolean;
'pin-device-to-release': boolean;
'pin-device-to-release'?: boolean;
'additional-space'?: number;
'add-certificate'?: string[];
help: void;
@ -122,7 +122,7 @@ https://github.com/balena-io-examples/staged-releases\
'disable architecture compatibility check between image and fleet',
}),
'pin-device-to-release': flags.boolean({
default: false,
allowNo: true,
description:
'pin the preloaded device to the preloaded release on provision',
char: 'p',
@ -230,7 +230,7 @@ Can be repeated to add multiple certificates.\
const splashImage = options['splash-image'];
const additionalSpace = options['additional-space'];
const dontCheckArch = options['dont-check-arch'] || false;
const pinDevice = options['pin-device-to-release'] || false;
const pinDevice = options['pin-device-to-release'];
if (dontCheckArch && !fleetSlug) {
throw new ExpectedError(
@ -257,7 +257,7 @@ Can be repeated to add multiple certificates.\
splashImage,
undefined, // TODO: Currently always undefined, investigate approach in ssh command.
dontCheckArch,
pinDevice,
pinDevice ?? false,
certificates,
additionalSpace,
);
@ -450,14 +450,14 @@ Can be repeated to add multiple certificates.\
async offerToDisableAutomaticUpdates(
application: Pick<Application, 'id' | 'should_track_latest_release'>,
commit: string,
pinDevice: boolean,
pinDevice: boolean | undefined,
) {
const balena = getBalenaSdk();
if (
this.isCurrentCommit(commit) ||
!application.should_track_latest_release ||
pinDevice
pinDevice != null
) {
return;
}
@ -476,8 +476,9 @@ through the web dashboard or programatically through the balena API / SDK.
Documentation about release policies and pinning can be found at:
https://www.balena.io/docs/learn/deploy/release-strategy/release-policy/
Alternatively, the --pin-device-to-release flag may be used to pin only the
preloaded device to the selected release.
Alternatively, the --pin-device-to-release or --no-pin-device-to-release flags may be used
to avoid this interactive confirmation and pin only the preloaded device to the selected release
or keep it unpinned respectively.
Would you like to disable automatic updates for this fleet now?\
`;
@ -511,7 +512,7 @@ Would you like to disable automatic updates for this fleet now?\
options: {
slug?: string;
commit?: string;
pinDevice: boolean;
pinDevice?: boolean;
},
) {
await preloader.prepare();

View File

@ -152,9 +152,9 @@ export default class SshCmd extends Command {
const { which } = await import('../utils/which');
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
const [whichProxytunnel, { username }, proxyUrl] = await Promise.all([
useProxy ? which('proxytunnel', false) : undefined,
sdk.auth.whoami(),
sdk.auth.getUserInfo(),
// note that `proxyUrl` refers to the balenaCloud "resin-proxy"
// service, currently "balena-devices.com", rather than some
// local proxy server URL
@ -208,7 +208,7 @@ export default class SshCmd extends Command {
port: options.port || 'cloud',
proxyCommand,
service: params.service,
username: username!,
username,
});
}

View File

@ -36,11 +36,11 @@ export default class WhoamiCmd extends Command {
const balena = getBalenaSdk();
const [username, email, url] = await Promise.all([
balena.auth.whoami(),
balena.auth.getEmail(),
const [{ username, email }, url] = await Promise.all([
balena.auth.getUserInfo(),
balena.settings.get('balenaUrl'),
]);
console.log(
getVisuals().table.vertical({ username, email, url }, [
'$account information$',

View File

@ -15,6 +15,8 @@
* limitations under the License.
*/
import type { BalenaSettingsStorage } from 'balena-settings-storage';
export interface ReleaseTimestampsByVersion {
[version: string]: string; // e.g. { '12.0.0': '2021-06-16T12:54:52.000Z' }
lastFetched: string; // ISO 8601 timestamp, e.g. '2021-06-27T16:46:10.000Z'
@ -46,7 +48,7 @@ export class DeprecationChecker {
readonly cacheFile = 'cachedReleaseTimestamps';
readonly now = new Date().getTime();
private initialized = false;
storage: ReturnType<typeof import('balena-settings-storage')>;
storage: BalenaSettingsStorage;
cachedTimestamps: ReleaseTimestampsByVersion;
nextMajorVersion: string; // semver without the 'v' prefix
@ -63,7 +65,7 @@ export class DeprecationChecker {
this.initialized = true;
const settings = await import('balena-settings-client');
const getStorage = await import('balena-settings-storage');
const { getStorage } = await import('balena-settings-storage');
const dataDirectory = settings.get<string>('dataDirectory');
this.storage = getStorage({ dataDirectory });
let stored: ReleaseTimestampsByVersion | undefined;

View File

@ -133,7 +133,6 @@ Please use "balena ${alternative}" instead.`);
'local stop': [removed, stopAlternative, 'v11.0.0'],
app: [replaced, 'fleet', 'v13.0.0'],
apps: [replaced, 'fleets', 'v13.0.0'],
'app create': [replaced, 'fleet create', 'v13.0.0'],
'app purge': [replaced, 'fleet purge', 'v13.0.0'],
'app rename': [replaced, 'fleet rename', 'v13.0.0'],
'app restart': [replaced, 'fleet restart', 'v13.0.0'],

View File

@ -0,0 +1,58 @@
import { ExpectedError } from '../errors';
import { getBalenaSdk } from './lazy';
export interface FlagsDef {
organization?: string;
type?: string; // application device type
help: void;
}
export interface ArgsDef {
name: string;
}
export async function applicationCreateBase(
resource: 'fleet' | 'app' | 'block',
options: FlagsDef,
params: ArgsDef,
) {
// Ascertain device type
const deviceType =
options.type || (await (await import('./patterns')).selectDeviceType());
// Ascertain organization
const organization =
options.organization?.toLowerCase() ||
(await (await import('./patterns')).getAndSelectOrganization());
// Create application
try {
const application = await getBalenaSdk().models.application.create({
name: params.name,
deviceType,
organization,
});
// Output
const { capitalize } = await import('lodash');
console.log(
`${capitalize(resource)} created: slug "${
application.slug
}", device type "${deviceType}"`,
);
} catch (err) {
if ((err.message || '').toLowerCase().includes('unique')) {
// BalenaRequestError: Request error: "organization" and "app_name" must be unique.
throw new ExpectedError(
`Error: An app or block or fleet with the name "${params.name}" already exists in organization "${organization}".`,
);
} else if ((err.message || '').toLowerCase().includes('unauthorized')) {
// BalenaRequestError: Request error: Unauthorized
throw new ExpectedError(
`Error: You are not authorized to create ${resource}s in organization "${organization}".`,
);
}
throw err;
}
}

View File

@ -138,7 +138,7 @@ export async function getCachedUsername(): Promise<CachedUsername | undefined> {
if (cachedUsername) {
return cachedUsername;
}
const [{ getBalenaSdk }, getStorage, settings] = await Promise.all([
const [{ getBalenaSdk }, { getStorage }, settings] = await Promise.all([
import('./lazy'),
import('balena-settings-storage'),
import('balena-settings-client'),
@ -167,7 +167,7 @@ export async function getCachedUsername(): Promise<CachedUsername | undefined> {
// ignore
}
try {
const username = await getBalenaSdk().auth.whoami();
const { username } = await getBalenaSdk().auth.getUserInfo();
if (username) {
cachedUsername = { token, username };
await storage.set('cachedUsername', cachedUsername);

View File

@ -588,7 +588,7 @@ async function assignDockerBuildOpts(
pull: opts.pull,
};
if (task.external) {
task.dockerOpts.authconfig = await getAuthConfigObj(
task.dockerOpts.authconfig = getAuthConfigObj(
task.imageName!,
opts.registrySecrets,
);

View File

@ -19,10 +19,10 @@ import type {
BalenaSDK,
Device,
Organization,
PineFilter,
PineOptions,
PineTypedResult,
} from 'balena-sdk';
import _ = require('lodash');
import { instanceOf, NotLoggedInError, ExpectedError } from '../errors';
import { getBalenaSdk, getVisuals, stripIndent, getCliForm } from './lazy';
@ -115,22 +115,22 @@ export function askLoginType() {
});
}
export function selectDeviceType() {
return getBalenaSdk()
.models.config.getDeviceTypes()
.then((deviceTypes) => {
deviceTypes = _.sortBy(deviceTypes, 'name').filter(
(dt) => dt.state !== 'DISCONTINUED',
);
return getCliForm().ask({
message: 'Device Type',
type: 'list',
choices: _.map(deviceTypes, ({ slug: value, name }) => ({
name,
value,
})),
});
});
export async function selectDeviceType() {
const sdk = getBalenaSdk();
let deviceTypes = await sdk.models.deviceType.getAllSupported();
if (deviceTypes.length === 0) {
// Without this open-balena users would get an empty list
// until we add a hostApps import in open-balena.
deviceTypes = await sdk.models.deviceType.getAll();
}
return getCliForm().ask({
message: 'Device Type',
type: 'list',
choices: deviceTypes.map(({ slug: value, name }) => ({
name,
value,
})),
});
}
/**
@ -179,27 +179,32 @@ type SelectApplicationResult = PineTypedResult<
>;
export async function selectApplication(
filter?: (app: SelectApplicationResult) => boolean,
filter?:
| PineFilter<Application>
| ((app: SelectApplicationResult) => boolean),
errorOnEmptySelection = false,
) {
const balena = getBalenaSdk();
const apps = (await balena.models.application.getAllDirectlyAccessible(
selectApplicationPineOptions,
)) as SelectApplicationResult[];
let apps = (await balena.models.application.getAllDirectlyAccessible({
...selectApplicationPineOptions,
...(filter != null && typeof filter === 'object' && { $filter: filter }),
})) as SelectApplicationResult[];
if (!apps.length) {
throw new ExpectedError('No fleets found');
}
const applications = filter ? apps.filter(filter) : apps;
if (filter != null && typeof filter === 'function') {
apps = apps.filter(filter);
}
if (errorOnEmptySelection && applications.length === 0) {
if (errorOnEmptySelection && apps.length === 0) {
throw new ExpectedError('No suitable fleets found for selection');
}
return getCliForm().ask({
message: 'Select an application',
type: 'list',
choices: _.map(applications, (application) => ({
choices: apps.map((application) => ({
name: `${application.app_name} (${application.slug}) [${application.is_for__device_type[0].slug}]`,
value: application,
})),
@ -223,6 +228,24 @@ export async function selectOrganization(
});
}
export async function getAndSelectOrganization() {
const { getOwnOrganizations } = await import('./sdk');
const organizations = await getOwnOrganizations(getBalenaSdk(), {
$select: ['name', 'handle'],
});
if (organizations.length === 0) {
// User is not a member of any organizations (should not happen).
throw new Error('This account is not a member of any organizations');
} else if (organizations.length === 1) {
// User is a member of only one organization - use this.
return organizations[0].handle;
} else {
// User is a member of multiple organizations -
return selectOrganization(organizations);
}
}
export async function awaitDeviceOsUpdate(
uuid: string,
targetOsVersion: string,
@ -338,7 +361,7 @@ export async function getOnlineTargetDeviceUuid(
const devices = application.owns__device;
// Throw if no devices online
if (_.isEmpty(devices)) {
if (!devices.length) {
throw new ExpectedError(
`Fleet ${application.slug} found, but has no devices online.`,
);
@ -349,7 +372,7 @@ export async function getOnlineTargetDeviceUuid(
message: `Select a device on fleet ${application.slug}`,
type: 'list',
default: devices[0].uuid,
choices: _.map(devices, (device) => ({
choices: devices.map((device) => ({
name: `${device.device_name || 'Untitled'} (${device.uuid.slice(0, 7)})`,
value: device.uuid,
})),
@ -363,7 +386,7 @@ export function selectFromList<T>(
return getCliForm().ask<T>({
message,
type: 'list',
choices: _.map(choices, (s) => ({
choices: choices.map((s) => ({
name: s.name,
value: s,
})),

View File

@ -228,8 +228,8 @@ async function selectLocalDevice(): Promise<string> {
}
async function selectAppFromList(
applications: ApplicationWithDeviceType[],
): Promise<ApplicationWithDeviceType> {
applications: ApplicationWithDeviceTypeSlug[],
): Promise<ApplicationWithDeviceTypeSlug> {
const _ = await import('lodash');
const { selectFromList } = await import('../utils/patterns');
@ -247,7 +247,7 @@ async function getOrSelectApplication(
sdk: BalenaSdk.BalenaSDK,
deviceTypeSlug: string,
appName?: string,
): Promise<ApplicationWithDeviceType> {
): Promise<ApplicationWithDeviceTypeSlug> {
const pineOptions = {
$select: 'slug',
$expand: {
@ -256,51 +256,72 @@ async function getOrSelectApplication(
},
},
} satisfies BalenaSdk.PineOptions<BalenaSdk.DeviceType>;
const [deviceType, allDeviceTypes] = await Promise.all([
sdk.models.deviceType.get(deviceTypeSlug, pineOptions) as Promise<
BalenaSdk.PineTypedResult<BalenaSdk.DeviceType, typeof pineOptions>
>,
sdk.models.deviceType.getAllSupported(pineOptions) as Promise<
Array<BalenaSdk.PineTypedResult<BalenaSdk.DeviceType, typeof pineOptions>>
>,
]);
const deviceType = (await sdk.models.deviceType.get(
deviceTypeSlug,
pineOptions,
)) as BalenaSdk.PineTypedResult<BalenaSdk.DeviceType, typeof pineOptions>;
const allCpuArches = await sdk.pine.get({
resource: 'cpu_architecture',
options: {
$select: ['id', 'slug'],
},
});
const compatibleDeviceTypes = allDeviceTypes
.filter((dt) =>
const compatibleCpuArchIds = allCpuArches
.filter((cpuArch) =>
sdk.models.os.isArchitectureCompatibleWith(
deviceType.is_of__cpu_architecture[0].slug,
dt.is_of__cpu_architecture[0].slug,
cpuArch.slug,
),
)
.map((type) => type.slug);
.map((cpu) => cpu.id);
if (!appName) {
return createOrSelectApp(sdk, compatibleDeviceTypes, deviceTypeSlug);
return createOrSelectApp(
sdk,
{
is_for__device_type: {
$any: {
$alias: 'dt',
$expr: {
dt: {
is_of__cpu_architecture: { $in: compatibleCpuArchIds },
},
},
},
},
},
deviceTypeSlug,
);
}
const options: BalenaSdk.PineOptions<BalenaSdk.Application> = {
const options = {
$expand: {
is_for__device_type: { $select: 'slug' },
is_for__device_type: { $select: ['slug', 'is_of__cpu_architecture'] },
},
};
} satisfies BalenaSdk.PineOptions<BalenaSdk.Application>;
// Check for a fleet slug of the form `user/fleet` and update the API query.
let name: string;
const match = appName.split('/');
if (match.length > 1) {
// These will match at most one fleet
options.$filter = { slug: appName.toLowerCase() };
(options as BalenaSdk.PineOptions<BalenaSdk.Application>).$filter = {
slug: appName.toLowerCase(),
};
name = match[1];
} else {
// We're given an application; resolve it if it's ambiguous and also validate
// it's of appropriate device type.
options.$filter = { app_name: appName };
(options as BalenaSdk.PineOptions<BalenaSdk.Application>).$filter = {
app_name: appName,
};
name = appName;
}
const applications = (await sdk.models.application.getAllDirectlyAccessible(
options,
)) as ApplicationWithDeviceType[];
)) as Array<BalenaSdk.PineTypedResult<BalenaSdk.Application, typeof options>>;
if (applications.length === 0) {
await confirm(
@ -315,8 +336,11 @@ async function getOrSelectApplication(
// We've found at least one fleet with the given name.
// Filter out fleets for non-matching device types and see what we're left with.
const compatibleCpuArchIdsSet = new Set(compatibleCpuArchIds);
const validApplications = applications.filter((app) =>
compatibleDeviceTypes.includes(app.is_for__device_type[0].slug),
compatibleCpuArchIdsSet.has(
app.is_for__device_type[0].is_of__cpu_architecture.__id,
),
);
if (validApplications.length === 0) {
@ -332,21 +356,14 @@ async function getOrSelectApplication(
async function createOrSelectApp(
sdk: BalenaSdk.BalenaSDK,
compatibleDeviceTypes: string[],
compatibleDeviceTypesFilter: BalenaSdk.PineFilter<BalenaSdk.Application>,
deviceType: string,
): Promise<ApplicationWithDeviceType> {
): Promise<ApplicationWithDeviceTypeSlug> {
// No fleet specified, show a list to select one.
const applications = (await sdk.models.application.getAllDirectlyAccessible({
$expand: { is_for__device_type: { $select: 'slug' } },
$filter: {
is_for__device_type: {
$any: {
$alias: 'dt',
$expr: { dt: { slug: { $in: compatibleDeviceTypes } } },
},
},
},
})) as ApplicationWithDeviceType[];
$filter: compatibleDeviceTypesFilter,
})) as ApplicationWithDeviceTypeSlug[];
if (applications.length === 0) {
await confirm(
@ -366,11 +383,14 @@ async function createApplication(
sdk: BalenaSdk.BalenaSDK,
deviceType: string,
name?: string,
): Promise<ApplicationWithDeviceType> {
): Promise<ApplicationWithDeviceTypeSlug> {
const validation = await import('./validation');
const username = await sdk.auth.whoami();
if (!username) {
let username: string;
try {
const userInfo = await sdk.auth.getUserInfo();
username = userInfo.username;
} catch (err) {
throw new sdk.errors.BalenaNotLoggedIn();
}
@ -387,7 +407,7 @@ async function createApplication(
try {
await sdk.models.application.getDirectlyAccessible(appName, {
$filter: {
slug: { $startswith: `${username!.toLowerCase()}/` },
slug: { $startswith: `${username.toLowerCase()}/` },
},
});
// TODO: This is the only example in the codebase where `printErrorMessage()`
@ -414,12 +434,12 @@ async function createApplication(
$expand: {
is_for__device_type: { $select: 'slug' },
},
})) as ApplicationWithDeviceType;
})) as ApplicationWithDeviceTypeSlug;
}
async function generateApplicationConfig(
sdk: BalenaSdk.BalenaSDK,
app: ApplicationWithDeviceType,
app: ApplicationWithDeviceTypeSlug,
options: {
version: string;
appUpdatePollInterval?: number;

View File

@ -105,7 +105,7 @@ export async function getOwnOrganizations(
$alias: 'orm',
$expr: {
orm: {
user: await sdk.auth.getUserId(),
user: (await sdk.auth.getUserInfo()).id,
},
},
},

View File

@ -51,7 +51,7 @@ export const tunnelConnectionToDevice = (
sdk.auth.getToken(),
]).then(([tunnelUrl, whoami, token]) => {
const auth = {
user: whoami || 'root',
user: whoami?.actorType === 'user' ? whoami.username : 'root',
password: token,
};

908
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "balena-cli",
"version": "16.6.3",
"version": "16.7.9",
"description": "The official balena Command Line Interface",
"main": "./build/app.js",
"homepage": "https://github.com/balena-io/balena-cli",
@ -193,7 +193,7 @@
"typescript": "^5.1.3"
},
"dependencies": {
"@balena/compose": "^2.2.1",
"@balena/compose": "^3.0.2",
"@balena/dockerignore": "^1.0.2",
"@balena/es-version": "^1.0.1",
"@oclif/command": "^1.8.16",
@ -205,12 +205,12 @@
"balena-device-init": "^6.0.0",
"balena-errors": "^4.7.3",
"balena-image-fs": "^7.0.6",
"balena-image-manager": "^9.0.0",
"balena-preload": "^14.0.0",
"balena-sdk": "^17.0.0",
"balena-image-manager": "^9.0.2",
"balena-preload": "^14.0.2",
"balena-sdk": "^18.0.0",
"balena-semver": "^2.3.0",
"balena-settings-client": "^5.0.2",
"balena-settings-storage": "^7.0.0",
"balena-settings-storage": "^8.1.0",
"bluebird": "^3.7.2",
"body-parser": "^1.19.1",
"chalk": "^3.0.0",
@ -284,6 +284,6 @@
"windosu": "^0.3.0"
},
"versionist": {
"publishedAt": "2023-06-30T17:07:33.152Z"
"publishedAt": "2023-08-22T13:33:26.774Z"
}
}

View File

@ -95,7 +95,7 @@ describe('balena envs', function () {
it('should successfully list service variables for a test fleet (-s flag)', async () => {
const serviceName = 'service2';
api.expectGetService({ serviceName });
api.expectGetServiceFromApp({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
@ -117,7 +117,7 @@ describe('balena envs', function () {
it('should successfully list env and service vars for a test fleet (-s flags)', async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetServiceFromApp({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
@ -216,7 +216,7 @@ describe('balena envs', function () {
it('should successfully list service variables for a test device (-s flag)', async () => {
const serviceName = 'service2';
api.expectGetService({ serviceName });
api.expectGetServiceFromApp({ serviceName });
api.expectGetApplication();
api.expectGetDevice({ shortUUID, fullUUID });
api.expectGetDevice({ fullUUID });
@ -269,7 +269,7 @@ describe('balena envs', function () {
it('should successfully list env and service vars for a test device (-s flags)', async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetServiceFromApp({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();
@ -299,7 +299,7 @@ describe('balena envs', function () {
it('should successfully list env and service vars for a test device (-js flags)', async () => {
const serviceName = 'service1';
api.expectGetService({ serviceName });
api.expectGetServiceFromApp({ serviceName });
api.expectGetApplication();
api.expectGetAppEnvVars();
api.expectGetAppServiceVars();

View File

@ -16,7 +16,7 @@
*/
import * as settings from 'balena-settings-client';
import * as getStorage from 'balena-settings-storage';
import { getStorage } from 'balena-settings-storage';
import { expect } from 'chai';
import mock = require('mock-require');
import * as semver from 'semver';
@ -78,7 +78,7 @@ describe('DeprecationChecker', function () {
.stub(mockStorage, 'set')
.withArgs(checker.cacheFile, sinon.match.any);
mock(storageModPath, () => mockStorage);
mock(storageModPath, { getStorage: () => mockStorage });
});
this.afterEach(() => {

View File

@ -386,6 +386,21 @@ export class BalenaAPIMock extends NockMock {
});
}
public expectGetServiceFromApp(opts: {
optional?: boolean;
persist?: boolean;
serviceId?: number;
serviceName: string;
}) {
const serviceId = opts.serviceId || 243768;
this.optGet(/^\/v6\/application($|\?).*\$expand=service.*/, opts).reply(
200,
{
d: [{ service: [{ id: serviceId, service_name: opts.serviceName }] }],
},
);
}
public expectPostService409(opts: ScopeOpts = {}) {
this.optPost(/^\/v\d+\/service$/, opts).reply(
409,
@ -415,8 +430,10 @@ export class BalenaAPIMock extends NockMock {
// User details are cached in the SDK
// so often we don't know if we can expect the whoami request
public expectGetWhoAmI(opts: ScopeOpts = { optional: true }) {
this.optGet('/user/v1/whoami', opts).reply(200, {
id: 99999,
this.optGet('/actor/v1/whoami', opts).reply(200, {
id: 1234,
actorType: 'user',
actorTypeId: 99999,
username: 'gh_user',
email: 'testuser@test.com',
});

7
typings/global.d.ts vendored
View File

@ -1,8 +1,11 @@
import { Application, DeviceType, Device } from 'balena-sdk';
declare global {
type ApplicationWithDeviceType = Application & {
is_for__device_type: [DeviceType];
type ApplicationWithDeviceTypeSlug = Omit<
Application,
'is_for__device_type'
> & {
is_for__device_type: [Pick<DeviceType, 'slug'>];
};
type DeviceWithDeviceType = Device & {
is_of__device_type: [DeviceType];