]> git.wincent.com - wincent.git/commitdiff
feat: partially implement `compare()` function
authorGreg Hurrell <greg@hurrell.net>
Tue, 31 Mar 2020 00:11:41 +0000 (02:11 +0200)
committerGreg Hurrell <greg@hurrell.net>
Tue, 31 Mar 2020 00:11:41 +0000 (02:11 +0200)
src/Fig/__tests__/__fixtures__/sample [new file with mode: 0644]
src/Fig/__tests__/compare-test.ts [new file with mode: 0644]
src/Fig/compare.ts [new file with mode: 0644]
src/Fig/operations/template.ts
src/console/index.ts
src/main.ts
src/test/harness.ts

diff --git a/src/Fig/__tests__/__fixtures__/sample b/src/Fig/__tests__/__fixtures__/sample
new file mode 100644 (file)
index 0000000..ea8f990
--- /dev/null
@@ -0,0 +1 @@
+sample contents
diff --git a/src/Fig/__tests__/compare-test.ts b/src/Fig/__tests__/compare-test.ts
new file mode 100644 (file)
index 0000000..3c79e7e
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * @file
+ *
+ * Some light tests for `compare()`: can't really test a lot of the
+ * permissions/ownership related stuff without relying on `sudo`, which
+ * we don't want to have to do in the test suite.
+ */
+
+import {join} from 'path';
+
+import {describe, expect, test} from '../../test/harness';
+import compare from '../compare';
+import root from '../root';
+
+/**
+ * Helper to get fixtures (in "src/") irrespective of where we run from.
+ */
+function fixture(...components: Array<string>): string {
+  return join(root, 'src', 'Fig', '__tests__', '__fixtures__', ...components);
+}
+
+describe('compare()', () => {
+  describe('with {state: file} (implied)', () => {
+    test('returns an "empty" object for files that match', async () => {
+      const path = fixture('sample');
+
+      const diff = await compare({path});
+
+      expect(diff).toEqual({path});
+    });
+  });
+
+  describe('with {state: file} (explicit)', () => {
+    test('returns an "empty" object for files that match', async () => {
+      const path = fixture('sample');
+
+      const diff = await compare({path, state: 'file'});
+
+      expect(diff).toEqual({path});
+    });
+
+    test('returns {state: "file"} for non-existent files', async () => {
+      const path = fixture('non-existent');
+
+      const diff = await compare({path, state: 'file'});
+
+      expect(diff).toEqual({
+        path,
+        state: 'file'
+      });
+    });
+
+    test('complains if parent directory does not exist', async () => {
+      const path = fixture('does', 'not', 'exist');
+
+      const diff = await compare({path, state: 'file'});
+
+      expect(diff.path).toEqual(path);
+      expect(diff.error!.message).toMatch(
+        /Cannot stat ".+" because parent ".+" does not exist/
+      );
+    });
+  });
+
+  describe('with {state: "absent"}', () => {
+    test('returns an "empty" object for missing files', async () => {
+      const path = fixture('non-existent');
+
+      const diff = await compare({path, state: 'absent'});
+
+      expect(diff).toEqual({
+        path,
+      });
+    });
+  });
+});
diff --git a/src/Fig/compare.ts b/src/Fig/compare.ts
new file mode 100644 (file)
index 0000000..fcd8ba5
--- /dev/null
@@ -0,0 +1,164 @@
+import {promises as fs} from 'fs';
+import {dirname} from 'path';
+
+import ErrorWithMetadata from '../ErrorWithMetadata';
+
+import type {Stats} from 'fs';
+
+// TODO: decide whether the Ansible definition of "force" (which we use below)
+// is the one that we want to actual stick with.
+
+/**
+ * Summary of differences between actual and desired state of a file-system
+ * object (ie. a file, directory, or link).
+ *
+ * Notable properties:
+ *
+ * -  error: Set if there is some reason why the comparison could not be
+ *    completed or the desired end-state cannot be produced (for
+ *    example, if we desired an object with a "state" of "file" at
+ *    "a/b/c", but there is already a file at "a/b", that will be an
+ *    error).
+ * -  force: When `true`, user is asking to create a symlink even if the
+ *    target doesn't exist, and even if there is a file already at the
+ *    destination (which would need to be removed for link creation to
+ *    succeed). In the result, set to `true` to signal that a target
+ *    will need to be removed (for example, to replace a link with a
+ *    directory), or force-written (for example, to replace a file with
+ *    a link; ie. `ln -sf`) etc.
+ * -  path: Read-only property, for book-keeping purposes. The only
+ *    non-optional property.
+ * -  state: A required property, but this one has a default ("file").
+ *
+ * In general, if a property is unset, that means no changes are
+ * required with respect to that property.
+ */
+type Diff = {
+  contents?: string;
+  error?: Error;
+  force?: boolean;
+  group?: string;
+  mode?: Mode;
+  owner?: string;
+  readonly path: string;
+  state?: 'absent' | 'directory' | 'file' | 'link' | 'touch';
+};
+
+type Compare = Omit<Diff, 'error'>;
+
+const {stringify} = JSON;
+
+/**
+ * Given a desired end-state of a file-system object (ie. a file,
+ * directory, or link), returns a "diff" of the changes that need to be
+ * made to the current file system to produce that desired end-state.
+ */
+export default async function compare({
+  contents,
+  force,
+  group,
+  mode,
+  owner,
+  path,
+  state = 'file',
+}: Compare) {
+  const diff: Diff = {path};
+
+  const stats = await lstat(path);
+
+  if (stats instanceof Error) {
+    // Can't stat; bail.
+    diff.error = stats;
+    return diff;
+  } else if (stats === null) {
+    // Object does not exist.
+    if (state === 'absent') {
+      // Want "absent", have "absent": no state change required.
+    } else {
+      // Distinguish between `path` itself not existing (in which case
+      // it can be created), and one of its parents not existing (in which case
+      // we have to bail).
+      const parent = dirname(path);
+      const stats = await lstat(parent);
+      if (stats instanceof Error) {
+        // Unlikely (we were able to stat object but not its parent).
+        diff.error = stats;
+        return diff;
+      } else if (stats === null) {
+        diff.error = new ErrorWithMetadata(
+          `Cannot stat ${stringify(path)} because parent ${stringify(parent)} does not exist`
+        );
+      } else {
+        // Parent exists.
+        diff.state = state;
+      }
+    }
+    // Nothing else we can check without the object existing.
+    return diff;
+  }
+
+  // Object exists.
+  if (state === 'file') {
+    if (stats.isFile()) {
+      // Want "file", have "file": no state change required.
+    } else if (stats.isSymbolicLink()) {
+      // Going to have to overwrite symlink.
+      diff.force = true;
+      diff.state = 'file';
+    } else if (stats.isDirectory()) {
+      diff.error = new ErrorWithMetadata(
+        `Cannot replace directory ${stringify(path)} with file`
+      );
+    } else {
+      // We're not going to bother with "exotic" types such as sockets etc.
+      diff.error = new ErrorWithMetadata(
+        `Cannot replace object ${stringify(path)} of unknown type with file`
+      );
+    }
+  } else if (state === 'directory') {
+    if (stats.isDirectory()) {
+      // Want "directory", have "directory": no state change required.
+    } else if (stats.isFile() || stats.isSymbolicLink()) {
+      if (force) {
+        // Will have to remove file/link before creating directory.
+        diff.force = true;
+        diff.state = state;
+      } else {
+        const entity = stats.isFile() ? 'file' : 'symbolic link';
+
+        diff.error = new ErrorWithMetadata(
+          `Cannot replace ${entity} ${stringify(path)} with directory without 'force'`
+        );
+      }
+    } else {
+      // We're not going to bother with "exotic" types such as sockets etc.
+      diff.error = new ErrorWithMetadata(
+        `Cannot replace object ${stringify(path)} of unknown type with directory`
+      );
+    }
+  } else if (state === 'link') {
+    // TODO
+  } else if (state === 'absent') {
+    // TODO
+  } else if (state === 'touch') {
+    // TODO
+  }
+
+  return diff;
+}
+
+/**
+ * Wrapper for `fs.lstat` that returns a `Stats` object when `path` exists and is
+ * accessible, `null` when the path does not exist, and an `Error` otherwise.
+ */
+async function lstat(path: string): Promise<Stats | Error | null> {
+  try {
+    return await fs.lstat(path);
+  } catch (error) {
+    if (error.code === 'ENOENT') {
+      return null;
+    } else {
+      return error;
+    }
+  }
+}
index 854adef41432db9e8a0e1bc96e182361938ee602..f98644b3ab5cd7cfec4bd6fd2b0863c7e65e6f88 100644 (file)
@@ -6,8 +6,10 @@ import expand from '../../expand';
 import sudo from '../../sudo';
 import {compile, fill} from '../../template';
 import Context from '../Context';
+import compare from '../compare';
 
 export default async function template({
+  force,
   group,
   mode,
   owner,
@@ -15,6 +17,7 @@ export default async function template({
   src,
   variables = {},
 }: {
+  force?: boolean;
   group?: string;
   path: string;
   mode?: Mode;
@@ -23,9 +26,22 @@ export default async function template({
   variables: Variables;
 }): Promise<void> {
   const target = expand(path);
+
   log.info(`template ${src} -> ${target}`);
 
-  const filled = (await Context.compile(src)).fill({variables});
+  const contents = (await Context.compile(src)).fill({variables});
+
+  const diff = await compare({
+    contents,
+    force,
+    group,
+    mode,
+    owner,
+    path,
+    state: 'file',
+  });
+
+  console.log(diff);
 
   if (owner && owner !== Context.attributes.username) {
     log.notice(`needs sudo: ${Context.attributes.username} -> ${owner}`);
@@ -47,12 +63,12 @@ export default async function template({
     // TODO extract this somewhere else
     // need low-level filesystem ops that are consumed by the high-level
     // user-accessible ops
-    let contents;
+    let current;
 
     if (fs.existsSync(target)) {
-      contents = fs.readFileSync(target, 'utf8');
+      current = fs.readFileSync(target, 'utf8');
 
-      if (contents !== filled) {
+      if (current !== contents) {
         log.info('change!');
       } else {
         log.info('no change');
index 374289a40608e419618aa3738d412f57068f9f72..f99303197482b8daeac44fcb8329b7d19018ec50 100644 (file)
@@ -104,6 +104,10 @@ export function print(...args: Array<any>) {
   );
 }
 
+export function getLogLevel(): LogLevel {
+  return logLevel;
+}
+
 export function setLogLevel(level: LogLevel) {
   logLevel = level;
 }
index 61dea7a17e8fcf7756c282f28c5a7ab8f18a702b..ba9630f5c2fad4bf662483522a3f3558be9722cd 100644 (file)
@@ -18,11 +18,19 @@ async function main() {
     throw new ErrorWithMetadata('Cannot run as root');
   }
 
+  let testsOnly = false;
+
   process.argv.forEach(arg => {
     if (arg === '--debug') {
       setLogLevel(LOG_LEVEL.DEBUG);
     } else if (arg === '--quiet' || arg === '-q') {
       setLogLevel(LOG_LEVEL.ERROR);
+    } else if (arg === '--test') {
+      testsOnly = true;
+    } else if (arg === '--help' || arg === '-h') {
+      // TODO: print and exit
+    } else {
+      // TODO: error for bad args
     }
   });
 
@@ -42,6 +50,10 @@ async function main() {
 
   await test();
 
+  if (testsOnly) {
+    return;
+  }
+
   const project = await readProject(path.join(root, 'project.json'));
 
   const hostname = os.hostname();
index 502ea38c2bfe37b63b6a2356524ebd4423698163..5fe88750f92dd9edaf51af3a91998a1ca3e88aac 100644 (file)
@@ -2,7 +2,7 @@ import * as assert from 'assert';
 
 import ErrorWithMetadata from '../ErrorWithMetadata';
 
-import {COLORS, log, print} from '../console';
+import {COLORS, LOG_LEVEL, getLogLevel, log, print} from '../console';
 
 const {green, red, yellow} = COLORS;
 
@@ -20,6 +20,14 @@ function stringify(value: unknown) {
   }
 }
 
+let context: Array<string> = [];
+
+export function describe(description: string, callback: () => void) {
+  context.push(description);
+  callback();
+  context.pop();
+}
+
 export function expect(value: unknown) {
   return {
     toBe(expected: unknown) {
@@ -38,6 +46,17 @@ export function expect(value: unknown) {
       );
     },
 
+    toMatch(expected: unknown) {
+      if ((expected instanceof RegExp)) {
+        assert(
+          expected.test(String(value)),
+          `Expected ${stringify(value)} to match ${stringify(expected)}`
+        );
+      } else {
+        throw new Error(`Expected RegExp but received ${typeof expected}`);
+      }
+    },
+
     toThrow(expected: string | typeof Error | RegExp) {
       let caught;
 
@@ -81,13 +100,20 @@ export function expect(value: unknown) {
   };
 }
 
-export function test(description: string, callback: () => void): void {
-  TESTS.push([description, callback]);
+const RAQUO = '\u00bb';
+
+export function test(description: string, callback: () => void) {
+  TESTS.push([
+    [...context, description].join(` ${RAQUO} `),
+    callback
+  ]);
 }
 
 export async function run() {
   const start = Date.now();
 
+  const logLevel = getLogLevel();
+
   let failureCount = 0;
   let successCount = 0;
 
@@ -98,8 +124,11 @@ export async function run() {
       print(yellow.reverse` TEST `, description);
       await callback();
       successCount++;
+      // BUG: doesn't clear if line is too wide for terminal
       await print.clear();
-      log(green.reverse` PASS `, description);
+      if (logLevel >= LOG_LEVEL.DEBUG) {
+        log(green.reverse` PASS `, description);
+      }
     } catch (error) {
       failureCount++;
       await print.clear();
@@ -124,6 +153,9 @@ export async function run() {
 
   log();
   log(`${successSummary}, ${failureSummary}, ${totalSummary}`);
+  if (logLevel < LOG_LEVEL.DEBUG) {
+    log('Rerun with --debug to see full results');
+  }
   log();
 
   if (failureCount) {