1
1
import { isLeft } from 'fp-ts/lib/Either' ;
2
2
import * as t from 'io-ts' ;
3
3
import Reporter from 'io-ts-reporters' ;
4
- import _ from 'lodash' ;
5
4
import { TypedError } from 'typed-error' ;
6
5
7
6
import type { ContractObject } from '@balena/contrato' ;
8
- import { Blueprint , Contract } from '@balena/contrato' ;
7
+ import { Contract , Universe } from '@balena/contrato' ;
9
8
10
- import { InternalInconsistencyError } from './errors' ;
11
9
import { checkTruthy } from './validation' ;
12
- import type { TargetApps } from '../types' ;
10
+ import { withDefault , type TargetApps } from '../types' ;
13
11
14
12
/**
15
13
* This error is thrown when a container contract does not
@@ -64,182 +62,122 @@ interface ServiceWithContract extends ServiceCtx {
64
62
optional : boolean ;
65
63
}
66
64
67
- type PotentialContractRequirements =
68
- | 'sw.supervisor'
69
- | 'sw.l4t'
70
- | 'hw.device-type'
71
- | 'arch.sw' ;
72
- type ContractRequirements = {
73
- [ key in PotentialContractRequirements ] ?: string ;
74
- } ;
75
-
76
- const contractRequirementVersions : ContractRequirements = { } ;
65
+ const validRequirementTypes = [
66
+ 'sw.supervisor' ,
67
+ 'sw.l4t' ,
68
+ 'hw.device-type' ,
69
+ 'arch.sw' ,
70
+ ] ;
71
+ const deviceContract : Universe = new Universe ( ) ;
77
72
78
73
export function initializeContractRequirements ( opts : {
79
74
supervisorVersion : string ;
80
75
deviceType : string ;
81
76
deviceArch : string ;
82
77
l4tVersion ?: string ;
83
78
} ) {
84
- contractRequirementVersions [ 'sw.supervisor' ] = opts . supervisorVersion ;
85
- contractRequirementVersions [ 'sw.l4t' ] = opts . l4tVersion ;
86
- contractRequirementVersions [ 'hw.device-type' ] = opts . deviceType ;
87
- contractRequirementVersions [ 'arch.sw' ] = opts . deviceArch ;
79
+ deviceContract . addChildren ( [
80
+ new Contract ( {
81
+ type : 'sw.supervisor' ,
82
+ version : opts . supervisorVersion ,
83
+ } ) ,
84
+ new Contract ( {
85
+ type : 'sw.application' ,
86
+ slug : 'balena-supervisor' ,
87
+ version : opts . supervisorVersion ,
88
+ } ) ,
89
+ new Contract ( {
90
+ type : 'hw.device-type' ,
91
+ slug : opts . deviceType ,
92
+ } ) ,
93
+ new Contract ( {
94
+ type : 'arch.sw' ,
95
+ slug : opts . deviceArch ,
96
+ } ) ,
97
+ ] ) ;
98
+
99
+ if ( opts . l4tVersion ) {
100
+ deviceContract . addChild (
101
+ new Contract ( {
102
+ type : 'sw.l4t' ,
103
+ version : opts . l4tVersion ,
104
+ } ) ,
105
+ ) ;
106
+ }
88
107
}
89
108
90
- function isValidRequirementType (
91
- requirementVersions : ContractRequirements ,
92
- requirement : string ,
93
- ) {
94
- return requirement in requirementVersions ;
109
+ function isValidRequirementType ( requirement : string ) {
110
+ return validRequirementTypes . includes ( requirement ) ;
95
111
}
96
112
113
+ // this is only exported for tests
97
114
export function containerContractsFulfilled (
98
115
servicesWithContract : ServiceWithContract [ ] ,
99
116
) : AppContractResult {
100
- const containers = servicesWithContract
101
- . map ( ( { contract } ) => contract )
102
- . filter ( ( c ) => c != null ) satisfies ContractObject [ ] ;
103
- const contractTypes = Object . keys ( contractRequirementVersions ) ;
104
-
105
- const blueprintMembership : Dictionary < number > = { } ;
106
- for ( const component of contractTypes ) {
107
- blueprintMembership [ component ] = 1 ;
108
- }
109
- const blueprint = new Blueprint (
110
- {
111
- ...blueprintMembership ,
112
- 'sw.container' : '1+' ,
113
- } ,
114
- {
115
- type : 'sw.runnable.configuration' ,
116
- slug : '{{children.sw.container.slug}}' ,
117
- } ,
118
- ) ;
119
-
120
- const universe = new Contract ( {
121
- type : 'meta.universe' ,
122
- } ) ;
123
-
124
- universe . addChildren (
125
- [
126
- ...getContractsFromVersions ( contractRequirementVersions ) ,
127
- ...containers ,
128
- ] . map ( ( c ) => new Contract ( c ) ) ,
129
- ) ;
130
-
131
- const solution = [ ...blueprint . reproduce ( universe ) ] ;
132
-
133
- if ( solution . length > 1 ) {
134
- throw new InternalInconsistencyError (
135
- 'More than one solution available for container contracts when only one is expected!' ,
136
- ) ;
137
- }
138
-
139
- if ( solution . length === 0 ) {
140
- return {
141
- valid : false ,
142
- unmetServices : servicesWithContract ,
143
- fulfilledServices : [ ] ,
144
- unmetAndOptional : [ ] ,
145
- } ;
146
- }
147
-
148
- // Detect how many containers are present in the resulting
149
- // solution
150
- const children = solution [ 0 ] . getChildren ( {
151
- types : new Set ( [ 'sw.container' ] ) ,
152
- } ) ;
153
-
154
- if ( children . length === containers . length ) {
155
- return {
156
- valid : true ,
157
- unmetServices : [ ] ,
158
- fulfilledServices : servicesWithContract ,
159
- unmetAndOptional : [ ] ,
160
- } ;
161
- } else {
162
- // If we got here, it means that at least one of the
163
- // container contracts was not fulfilled. If *all* of
164
- // those containers whose contract was not met are
165
- // marked as optional, the target state is still valid,
166
- // but we ignore the optional containers
167
- const [ fulfilledServices , unfulfilledServices ] = _ . partition (
168
- servicesWithContract ,
169
- ( { contract } ) => {
170
- if ( ! contract ) {
171
- return true ;
172
- }
173
- // Did we find the contract in the generated state?
174
- return children . some ( ( child ) =>
175
- _ . isEqual ( ( child as any ) . raw , contract ) ,
176
- ) ;
177
- } ,
178
- ) ;
179
-
180
- const [ unmetAndRequired , unmetAndOptional ] = _ . partition (
181
- unfulfilledServices ,
182
- ( { optional } ) => ! optional ,
183
- ) ;
184
-
185
- return {
186
- valid : unmetAndRequired . length === 0 ,
187
- unmetServices : unfulfilledServices ,
188
- fulfilledServices,
189
- unmetAndOptional,
190
- } ;
191
- }
192
- }
193
-
194
- const contractObjectValidator = t . type ( {
195
- slug : t . string ,
196
- requires : t . union ( [
197
- t . null ,
198
- t . undefined ,
199
- t . array (
200
- t . type ( {
201
- type : t . string ,
202
- version : t . union ( [ t . null , t . undefined , t . string ] ) ,
203
- } ) ,
204
- ) ,
205
- ] ) ,
206
- } ) ;
207
-
208
- function getContractsFromVersions ( components : ContractRequirements ) {
209
- return _ . map ( components , ( value , component ) => {
210
- if ( component === 'hw.device-type' || component === 'arch.sw' ) {
211
- return {
212
- type : component ,
213
- slug : value ,
214
- name : value ,
215
- } ;
117
+ const unmetServices : ServiceCtx [ ] = [ ] ;
118
+ const unmetAndOptional : ServiceCtx [ ] = [ ] ;
119
+ const fulfilledServices : ServiceCtx [ ] = [ ] ;
120
+ for ( const svc of servicesWithContract ) {
121
+ if (
122
+ svc . contract != null &&
123
+ ! deviceContract . satisfiesChildContract ( new Contract ( svc . contract ) )
124
+ ) {
125
+ unmetServices . push ( svc ) ;
126
+ if ( svc . optional ) {
127
+ unmetAndOptional . push ( svc ) ;
128
+ }
216
129
} else {
217
- return {
218
- type : component ,
219
- slug : component ,
220
- name : component ,
221
- version : value ,
222
- } ;
130
+ fulfilledServices . push ( svc ) ;
223
131
}
224
- } ) ;
132
+ }
133
+
134
+ return {
135
+ valid : unmetServices . length - unmetAndOptional . length === 0 ,
136
+ unmetServices,
137
+ fulfilledServices,
138
+ unmetAndOptional,
139
+ } ;
225
140
}
226
141
227
- export function validateContract ( contract : unknown ) : boolean {
228
- const result = contractObjectValidator . decode ( contract ) ;
142
+ const ContainerContract = t . intersection ( [
143
+ t . type ( {
144
+ type : withDefault ( t . string , 'sw.container' ) ,
145
+ } ) ,
146
+ t . partial ( {
147
+ slug : t . union ( [ t . null , t . undefined , t . string ] ) ,
148
+ requires : t . union ( [
149
+ t . null ,
150
+ t . undefined ,
151
+ t . array (
152
+ t . intersection ( [
153
+ t . type ( {
154
+ type : t . string ,
155
+ } ) ,
156
+ t . partial ( {
157
+ slug : t . union ( [ t . null , t . undefined , t . string ] ) ,
158
+ version : t . union ( [ t . null , t . undefined , t . string ] ) ,
159
+ } ) ,
160
+ ] ) ,
161
+ ) ,
162
+ ] ) ,
163
+ } ) ,
164
+ ] ) ;
165
+
166
+ // Exported for tests only
167
+ export function parseContract ( contract : unknown ) : ContractObject {
168
+ const result = ContainerContract . decode ( contract ) ;
229
169
230
170
if ( isLeft ( result ) ) {
231
171
throw new Error ( Reporter . report ( result ) . join ( '\n' ) ) ;
232
172
}
233
173
234
- const requirementVersions = contractRequirementVersions ;
235
-
236
174
for ( const { type } of result . right . requires || [ ] ) {
237
- if ( ! isValidRequirementType ( requirementVersions , type ) ) {
175
+ if ( ! isValidRequirementType ( type ) ) {
238
176
throw new Error ( `${ type } is not a valid contract requirement type` ) ;
239
177
}
240
178
}
241
179
242
- return true ;
180
+ return result . right ;
243
181
}
244
182
245
183
export function validateTargetContracts (
@@ -261,7 +199,7 @@ export function validateTargetContracts(
261
199
( [ serviceName , { contract, labels = { } } ] ) => {
262
200
if ( contract ) {
263
201
try {
264
- validateContract ( contract ) ;
202
+ contract = parseContract ( contract ) ;
265
203
} catch ( e : any ) {
266
204
throw new ContractValidationError ( serviceName , e . message ) ;
267
205
}
0 commit comments