Skip to content

Commit e917b5a

Browse files
authored
Deploy with Manifests from URLs (#251)
* added functionality, need to add/modify existing tests * added tests * updated readme * prettier
1 parent 57d0489 commit e917b5a

File tree

6 files changed

+217
-23
lines changed

6 files changed

+217
-23
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Following are the key capabilities of this action:
5151
</tr>
5252
<tr>
5353
<td>manifests </br></br>(Required)</td>
54-
<td>Path to the manifest files to be used for deployment. These can also be directories containing manifest files, in which case, all manifest files in the referenced directory at every depth will be deployed. Files not ending in .yml or .yaml will be ignored.</td>
54+
<td>Path to the manifest files to be used for deployment. These can also be directories containing manifest files, in which case, all manifest files in the referenced directory at every depth will be deployed, or URLs to manifest files (like https://raw.githubusercontent.com/kubernetes/website/main/content/en/examples/controllers/nginx-deployment.yaml). Files and URLs not ending in .yml or .yaml will be ignored.</td>
5555
</tr>
5656
<tr>
5757
<td>strategy </br></br>(Required)</td>

jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ module.exports = {
66
transform: {
77
'^.+\\.ts$': 'ts-jest'
88
},
9-
verbose: true
9+
verbose: true,
10+
testTimeout: 9000
1011
}

src/run.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {promote} from './actions/promote'
55
import {reject} from './actions/reject'
66
import {Action, parseAction} from './types/action'
77
import {parseDeploymentStrategy} from './types/deploymentStrategy'
8-
import {getFilesFromDirectories} from './utilities/fileUtils'
8+
import {getFilesFromDirectoriesAndURLs} from './utilities/fileUtils'
99
import {PrivateKubectl} from './types/privatekubectl'
1010

1111
export async function run() {
@@ -26,7 +26,9 @@ export async function run() {
2626
.map((manifest) => manifest.trim()) // remove surrounding whitespace
2727
.filter((manifest) => manifest.length > 0) // remove any blanks
2828

29-
const fullManifestFilePaths = getFilesFromDirectories(manifestFilePaths)
29+
const fullManifestFilePaths = await getFilesFromDirectoriesAndURLs(
30+
manifestFilePaths
31+
)
3032
const kubectlPath = await getKubectlPath()
3133
const namespace = core.getInput('namespace') || 'default'
3234
const isPrivateCluster =

src/types/errorable.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export interface Succeeded<T> {
2+
readonly succeeded: true
3+
readonly result: T
4+
}
5+
6+
export interface Failed {
7+
readonly succeeded: false
8+
readonly error: string
9+
}
10+
11+
export type Errorable<T> = Succeeded<T> | Failed
12+
13+
export function succeeded<T>(e: Errorable<T>): e is Succeeded<T> {
14+
return e.succeeded
15+
}
16+
17+
export function failed<T>(e: Errorable<T>): e is Failed {
18+
return !e.succeeded
19+
}
20+
21+
export function map<T, U>(e: Errorable<T>, fn: (t: T) => U): Errorable<U> {
22+
if (failed(e)) {
23+
return {succeeded: false, error: e.error}
24+
}
25+
return {succeeded: true, result: fn(e.result)}
26+
}
27+
28+
export function combine<T>(es: Errorable<T>[]): Errorable<T[]> {
29+
const failures = es.filter(failed)
30+
if (failures.length > 0) {
31+
return {
32+
succeeded: false,
33+
error: failures.map((f) => f.error).join('\n')
34+
}
35+
}
36+
37+
return {
38+
succeeded: true,
39+
result: es.map((e) => (e as Succeeded<T>).result)
40+
}
41+
}
42+
43+
export function getErrorMessage(error: unknown) {
44+
if (error instanceof Error) {
45+
return error.message
46+
}
47+
return String(error)
48+
}

src/utilities/fileUtils.test.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,45 @@
1-
import {getFilesFromDirectories} from './fileUtils'
1+
import {
2+
getFilesFromDirectoriesAndURLs,
3+
getTempDirectory,
4+
urlFileKind,
5+
writeYamlFromURLToFile
6+
} from './fileUtils'
27

8+
import * as yaml from 'js-yaml'
9+
import * as fs from 'fs'
310
import * as path from 'path'
11+
import {succeeded} from '../types/errorable'
412

13+
const sampleYamlUrl =
14+
'https://raw.githubusercontent.com/kubernetes/website/main/content/en/examples/controllers/nginx-deployment.yaml'
515
describe('File utils', () => {
6-
it('detects files in nested directories and ignores non-manifest files and empty dirs', () => {
16+
test('correctly parses a yaml file from a URL', async () => {
17+
const tempFile = await writeYamlFromURLToFile(sampleYamlUrl, 0)
18+
const fileContents = fs.readFileSync(tempFile).toString()
19+
const inputObjects = yaml.safeLoadAll(fileContents)
20+
expect(inputObjects).toHaveLength(1)
21+
22+
for (const obj of inputObjects) {
23+
expect(obj.metadata.name).toBe('nginx-deployment')
24+
expect(obj.kind).toBe('Deployment')
25+
}
26+
})
27+
28+
it('fails when a bad URL is given among other files', async () => {
29+
const badUrl = 'https://www.github.com'
30+
731
const testPath = path.join('test', 'unit', 'manifests')
8-
const testSearch: string[] = getFilesFromDirectories([testPath])
32+
await expect(
33+
getFilesFromDirectoriesAndURLs([testPath, badUrl])
34+
).rejects.toThrow()
35+
})
36+
37+
it('detects files in nested directories and ignores non-manifest files and empty dirs', async () => {
38+
const testPath = path.join('test', 'unit', 'manifests')
39+
const testSearch: string[] = await getFilesFromDirectoriesAndURLs([
40+
testPath,
41+
sampleYamlUrl
42+
])
943

1044
const expectedManifests = [
1145
'test/unit/manifests/manifest_test_dir/another_layer/deep-ingress.yaml',
@@ -17,13 +51,18 @@ describe('File utils', () => {
1751
]
1852

1953
// is there a more efficient way to test equality w random order?
20-
expect(testSearch).toHaveLength(7)
54+
expect(testSearch).toHaveLength(8)
2155
expectedManifests.forEach((fileName) => {
22-
expect(testSearch).toContain(fileName)
56+
if (fileName.startsWith('test/unit')) {
57+
expect(testSearch).toContain(fileName)
58+
} else {
59+
expect(fileName.includes(urlFileKind)).toBe(true)
60+
expect(fileName.startsWith(getTempDirectory()))
61+
}
2362
})
2463
})
2564

26-
it('crashes when an invalid file is provided', () => {
65+
it('crashes when an invalid file is provided', async () => {
2766
const badPath = path.join('test', 'unit', 'manifests', 'nonexistent.yaml')
2867
const goodPath = path.join(
2968
'test',
@@ -32,12 +71,12 @@ describe('File utils', () => {
3271
'manifest_test_dir'
3372
)
3473

35-
expect(() => {
36-
getFilesFromDirectories([badPath, goodPath])
37-
}).toThrowError()
74+
expect(
75+
getFilesFromDirectoriesAndURLs([badPath, goodPath])
76+
).rejects.toThrowError()
3877
})
3978

40-
it("doesn't duplicate files when nested dir included", () => {
79+
it("doesn't duplicate files when nested dir included", async () => {
4180
const outerPath = path.join('test', 'unit', 'manifests')
4281
const fileAtOuter = path.join(
4382
'test',
@@ -53,11 +92,16 @@ describe('File utils', () => {
5392
)
5493

5594
expect(
56-
getFilesFromDirectories([outerPath, fileAtOuter, innerPath])
95+
await getFilesFromDirectoriesAndURLs([
96+
outerPath,
97+
fileAtOuter,
98+
innerPath
99+
])
57100
).toHaveLength(7)
58101
})
59-
})
60102

61-
// files that don't exist / nested files that don't exist / something else with non-manifest
62-
// lots of combinations of pointing to a directory and non yaml/yaml file
63-
// similarly named files in different folders
103+
it('throws an error for an invalid URL', async () => {
104+
const badUrl = 'https://www.github.com'
105+
await expect(writeYamlFromURLToFile(badUrl, 0)).rejects.toBeTruthy()
106+
})
107+
})

src/utilities/fileUtils.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import * as fs from 'fs'
2+
import * as https from 'https'
23
import * as path from 'path'
34
import * as core from '@actions/core'
45
import * as os from 'os'
6+
import * as yaml from 'js-yaml'
7+
import {Errorable, succeeded, failed, Failed} from '../types/errorable'
58
import {getCurrentTime} from './timeUtils'
9+
import {isHttpUrl} from './githubUtils'
10+
import {K8sObject} from '../types/k8sObject'
11+
12+
export const urlFileKind = 'urlfile'
613

714
export function getTempDirectory(): string {
815
return process.env['runner.tempDirectory'] || os.tmpdir()
@@ -62,12 +69,27 @@ function getManifestFileName(kind: string, name: string) {
6269
return path.join(tempDirectory, path.basename(filePath))
6370
}
6471

65-
export function getFilesFromDirectories(filePaths: string[]): string[] {
72+
export async function getFilesFromDirectoriesAndURLs(
73+
filePaths: string[]
74+
): Promise<string[]> {
6675
const fullPathSet: Set<string> = new Set<string>()
6776

68-
filePaths.forEach((fileName) => {
77+
let fileCounter = 0
78+
for (const fileName of filePaths) {
6979
try {
70-
if (fs.lstatSync(fileName).isDirectory()) {
80+
if (isHttpUrl(fileName)) {
81+
try {
82+
const tempFilePath: string = await writeYamlFromURLToFile(
83+
fileName,
84+
fileCounter++
85+
)
86+
fullPathSet.add(tempFilePath)
87+
} catch (e) {
88+
throw Error(
89+
`encountered error trying to pull YAML from URL ${fileName}: ${e}`
90+
)
91+
}
92+
} else if (fs.lstatSync(fileName).isDirectory()) {
7193
recurisveManifestGetter(fileName).forEach((file) => {
7294
fullPathSet.add(file)
7395
})
@@ -86,9 +108,86 @@ export function getFilesFromDirectories(filePaths: string[]): string[] {
86108
`Exception occurred while reading the file ${fileName}: ${ex}`
87109
)
88110
}
111+
}
112+
113+
const arr = Array.from(fullPathSet)
114+
return arr
115+
}
116+
117+
export async function writeYamlFromURLToFile(
118+
url: string,
119+
fileNumber: number
120+
): Promise<string> {
121+
return new Promise((resolve, reject) => {
122+
https
123+
.get(url, async (response) => {
124+
const code = response.statusCode ?? 0
125+
if (code >= 400) {
126+
reject(
127+
Error(
128+
`received response status ${response.statusMessage} from url ${url}`
129+
)
130+
)
131+
}
132+
133+
const targetPath = getManifestFileName(
134+
urlFileKind,
135+
fileNumber.toString()
136+
)
137+
// save the file to disk
138+
const fileWriter = fs
139+
.createWriteStream(targetPath)
140+
.on('finish', () => {
141+
const verification = verifyYaml(targetPath, url)
142+
if (succeeded(verification)) {
143+
core.debug(
144+
`outputting YAML contents from ${url} to ${targetPath}: ${JSON.stringify(
145+
verification.result
146+
)}`
147+
)
148+
resolve(targetPath)
149+
} else {
150+
reject(verification.error)
151+
}
152+
})
153+
154+
response.pipe(fileWriter)
155+
})
156+
.on('error', (error) => {
157+
reject(error)
158+
})
89159
})
160+
}
161+
162+
function verifyYaml(filepath: string, url: string): Errorable<K8sObject[]> {
163+
const fileContents = fs.readFileSync(filepath).toString()
164+
let inputObjects
165+
try {
166+
inputObjects = yaml.safeLoadAll(fileContents)
167+
} catch (e) {
168+
return {
169+
succeeded: false,
170+
error: `failed to parse manifest from url ${url}: ${e}`
171+
}
172+
}
173+
174+
if (!inputObjects || inputObjects.length == 0) {
175+
return {
176+
succeeded: false,
177+
error: `failed to parse manifest from url ${url}: no objects detected in manifest`
178+
}
179+
}
180+
181+
for (const obj of inputObjects) {
182+
if (!obj.kind || !obj.apiVersion || !obj.metadata) {
183+
return {
184+
succeeded: false,
185+
error: `failed to parse manifest from ${url}: missing fields`
186+
}
187+
}
188+
}
90189

91-
return Array.from(fullPathSet)
190+
return {succeeded: true, result: inputObjects}
92191
}
93192

94193
function recurisveManifestGetter(dirName: string): string[] {

0 commit comments

Comments
 (0)